1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 05:59:05 +03:00

cluster0: lab seeds, Gitea mirrors, compose mail, verify tooling

Freeze lab-seeds backend for COMPOSE_SKIP_MONOLITH; fix Gitea mirror scripts
for 1.25 API; add secrets.lab.env template, verify-mail-lab, LAB_PUBLIC_ORIGIN,
and credentials pointers for mirror token recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-23 17:07:46 +02:00
parent 98f30786b7
commit c554dc761d
209 changed files with 31249 additions and 6 deletions

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '', true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
}
$action = (string) ($data['action'] ?? 'register');
if ($action === 'verify') {
$result = AuthRegistration::verifyEmailToken((string) ($data['token'] ?? ''));
json_out($result, $result['ok'] ? 200 : 400);
}
$email = (string) ($data['email'] ?? '');
$password = (string) ($data['password'] ?? '');
$username = (string) ($data['username'] ?? '');
$result = AuthRegistration::register($email, $password, $username);
json_out($result, $result['ok'] ? 200 : 400);

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

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
json_out(['ok' => false, 'error' => 'empty 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);
}
if (($data['ingest_kind'] ?? 'graph_session') !== 'graph_session') {
json_out(['ok' => false, 'error' => 'ingest_kind must be graph_session'], 400);
}
try {
GraphRepository::insertSession($data);
json_out(['ok' => true, 'session_id' => $data['session_id'] ?? null]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('graph upload: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'store_failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
$days = (int) ($_GET['days'] ?? 14);
if ($days <= 0) {
$days = 14;
}
try {
$payload = GraphRepository::dashboardData($days);
json_out(['ok' => true, 'data' => $payload]);
} catch (Throwable $e) {
error_log('graphs api: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'graphs_load_failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
$raw = file_get_contents('php://input') ?: '';
$req = json_decode($raw, true);
if (!is_array($req)) {
http_response_code(400);
echo json_encode(['error' => 'invalid_json']);
exit;
}
$heartbeat = $req['heartbeat'] ?? [];
if (!is_array($heartbeat)) {
$heartbeat = [];
}
$type = (string)($heartbeat['type'] ?? 'generic');
$srvEpoch = time();
$srvTz = date('O');
$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
$resp = [
'heartbeat' => [
'type' => $type,
'server_date' => $srvEpoch,
'server_tz' => $srvTz,
],
];
if ($type === 'ntp') {
$clientDate = (int)($heartbeat['date'] ?? 0);
$clientTz = (string)($heartbeat['tz'] ?? '');
$timeSource = trim((string)($heartbeat['time_source'] ?? 'device'));
if ($timeSource === '') {
$timeSource = 'device';
}
$correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
$resp['heartbeat']['client_date'] = $clientDate;
$resp['heartbeat']['client_tz'] = $clientTz;
$resp['heartbeat']['time_source'] = $timeSource;
$resp['heartbeat']['correction_seconds'] = $correction;
$resp['heartbeat']['ntp_correction_s'] = $correction;
$resp['heartbeat']['enabled'] = true;
}
if ($type === 'ip') {
$resp['heartbeat']['external_ip'] = $clientIp;
}
if ($type === 'ra') {
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
$ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp);
$resp['heartbeat'] = array_merge($resp['heartbeat'], $ra);
}
echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
$action = strtolower(trim((string)($_GET['action'] ?? '')));
$section = strtolower(trim((string)($_GET['section'] ?? '')));
function hub_docs_toc(): array
{
return [
[
'id' => 'intro',
'title' => 'INTRODUCTION',
'children' => [
['id' => 'intro-authors', 'title' => 'Authors'],
['id' => 'intro-revision', 'title' => 'Revision details'],
],
],
[
'id' => 'deploy',
'title' => 'LOCAL SINGLE-STEP TEST DEPLOYMENT',
'children' => [
['id' => 'deploy-prep', 'title' => 'Preparations, environment'],
['id' => 'deploy-packages', 'title' => 'Package prerequisites by distro'],
['id' => 'deploy-checks', 'title' => 'Local checks'],
],
],
['id' => 'orchestration', 'title' => 'ORCHESTRATION MODEL', 'children' => []],
['id' => 'routing', 'title' => 'XHR SUPERVISED VIEW MODEL', 'children' => []],
[
'id' => 'ops',
'title' => 'OPERATIONS CHECKLIST',
'children' => [],
],
];
}
function hub_docs_section_html(string $section): string
{
switch ($section) {
case 'intro-authors':
return '<p>Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.</p>';
case 'intro-revision':
return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>';
case 'deploy-prep':
return '<p>Prerequisites: docker + docker compose plugin, git checkout of android cast on branch <code>next</code>.</p>'
. '<p>Run one command from repo: <code>cd orchestration && ./deploy.sh</code></p>';
case 'deploy-packages':
return '<table><thead><tr><th>Distro</th><th>Install steps</th></tr></thead><tbody>'
. '<tr><td>Alpine</td><td><code>apk add docker docker-cli-compose bash git curl</code><br><code>rc-update add docker default && service docker start</code></td></tr>'
. '<tr><td>Debian / Ubuntu</td><td><code>apt update && apt install -y docker.io docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '<tr><td>Fedora</td><td><code>dnf install -y docker docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '<tr><td>Arch</td><td><code>pacman -S --noconfirm docker docker-compose git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '</tbody></table>';
case 'deploy-checks':
return '<ul>'
. '<li><code>http://localhost:8080/app/androidcast_project/</code> (hub)</li>'
. '<li><code>http://localhost:8080/app/androidcast_project/crashes/</code> (crashes/tasks)</li>'
. '<li><code>http://localhost:8080/app/androidcast_project/git/</code> (redirect to gitea FE)</li>'
. '</ul>';
case 'orchestration':
return '<p>All infra runs in docker with shared source bind-mount where possible.</p>'
. '<ul>'
. '<li>landing FE (nginx): serves hub and routes subpaths</li>'
. '<li>gitea FE (nginx) + gitea app</li>'
. '<li>crashes FE (nginx) + php-fpm + mariadb</li>'
. '<li>single command create/update: <code>orchestration/deploy.sh</code></li>'
. '</ul>'
. '<p>Base images: nginx, php-fpm, mariadb. This stays minimal and reproducible on laptop devices.</p>';
case 'routing':
return '<p>The Compass click opens a modal. Content is requested via XMLHttpRequest/fetch from BE endpoint '
. '<code>/app/androidcast_project/crashes/api/hub_deploy_docs.php</code>.</p>'
. '<p>Internal links in this modal use tree-list captions and are intercepted on frontend, then re-requested through BE. '
. 'Browser URL bar is not changed.</p>'
. '<p>Static assets remain at original paths and are not rewritten in this view.</p>';
case 'ops':
return '<ol>'
. '<li>Pull latest <code>next</code></li>'
. '<li>Run <code>orchestration/deploy.sh</code></li>'
. '<li>Verify hub/crashes/gitea endpoints</li>'
. '<li>For app changes: edit source in repo, rerun deploy script</li>'
. '</ol>';
case 'intro':
return '<p>Select a topic in the table of contents to load section content from backend.</p>';
default:
return '<p class="muted">Section not found.</p>';
}
}
function hub_docs_section_title(string $section): string
{
foreach (hub_docs_toc() as $node) {
if ($node['id'] === $section) {
return (string)$node['title'];
}
foreach ($node['children'] as $child) {
if ($child['id'] === $section) {
return (string)$child['title'];
}
}
}
return 'Section';
}
function hub_docs_all_section_ids(): array
{
$ids = [];
foreach (hub_docs_toc() as $node) {
if (!empty($node['children'])) {
foreach ($node['children'] as $child) {
$ids[] = (string)$child['id'];
}
} else {
$ids[] = (string)$node['id'];
}
}
return $ids;
}
if ($action === 'toc') {
echo safe_json_encode([
'ok' => true,
'sections' => hub_docs_toc(),
'default_section' => 'intro-authors',
'preload' => hub_docs_all_section_ids(),
]);
exit;
}
if ($section === '') {
$section = 'intro-authors';
}
$known = hub_docs_all_section_ids();
if (!in_array($section, $known, true)) {
$section = 'intro-authors';
}
echo safe_json_encode([
'ok' => true,
'section' => $section,
'title' => hub_docs_section_title($section),
'html' => hub_docs_section_html($section),
]);

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,70 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
require_once __DIR__ . '/../../src/RbacAdminRepository.php';
header('Content-Type: application/json; charset=utf-8');
Auth::check();
if (!Rbac::canManageRbac()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = trim((string) ($_GET['action'] ?? ''));
if ($method === 'GET' && $action === 'panel') {
$panel = RbacAdminRepository::buildPanel();
json_out($panel, empty($panel['ok']) ? 403 : 200);
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
}
if ($action === 'set_global_role') {
$result = RbacAdminRepository::setGlobalRole(
(int) ($body['user_id'] ?? 0),
(string) ($body['role'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
if ($action === 'set_company_role') {
$result = RbacAdminRepository::setCompanyRole(
(int) ($body['user_id'] ?? 0),
(int) ($body['company_id'] ?? 0),
(string) ($body['role'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
if ($action === 'apply_privilege_set') {
$result = RbacAdminRepository::applyPrivilegeSet(
(int) ($body['user_id'] ?? 0),
(int) ($body['company_id'] ?? 0),
(string) ($body['privilege_set'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
if ($action === 'clear_auth_lockouts') {
if (!Rbac::isRoot() && !Rbac::isGlobalAdmin()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$userId = (int) ($body['user_id'] ?? 0);
if ($userId <= 0) {
json_out(['ok' => false, 'error' => 'invalid_user'], 400);
}
$deleted = AuthAttempts::clearForUser($userId);
json_out(['ok' => true, 'cleared' => $deleted]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
header('Content-Type: application/json; charset=utf-8');
Auth::check();
if (!Rbac::can('remote_access_view')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = trim((string)($_GET['action'] ?? ''));
if ($method === 'GET') {
if ($action === 'devices') {
json_out(['ok' => true, 'devices' => RemoteAccessRepository::listDevices()]);
}
if ($action === 'sessions') {
$filter = trim((string)($_GET['status'] ?? ''));
json_out(['ok' => true, 'sessions' => RemoteAccessRepository::listSessions($filter)]);
}
if ($action === 'events') {
json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]);
}
if ($action === 'dashboard') {
$payload = RemoteAccessRepository::buildDashboardPayload(
rtrim(Auth::basePath(), '/'),
'/app/androidcast_project'
);
json_out([
'ok' => true,
'devices' => $payload['devices'],
'active_sessions' => $payload['active_sessions'],
'inactive_sessions' => $payload['inactive_sessions'],
'recent_events' => $payload['recent_events'],
'permissions' => [
'can_operate' => Rbac::can('remote_access_operate'),
'can_admin' => Rbac::can('remote_access_admin'),
],
'config' => [
'wg_endpoint' => (string) cfg('remote_access.wg_endpoint', ''),
],
]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
}
$user = Auth::user();
$userId = (int)($user['id'] ?? 0);
if ($action === 'whitelist') {
if (!Rbac::can('remote_access_admin')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$deviceId = trim((string)($body['device_id'] ?? ''));
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
if (!RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
'whitelisted' => !empty($body['whitelisted']),
], '');
json_out(['ok' => true]);
}
if ($action === 'open_session') {
if (!Rbac::can('remote_access_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$deviceId = trim((string)($body['device_id'] ?? ''));
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$result = RemoteAccessRepository::openSession($deviceId, $userId);
if (empty($result['ok'])) {
$err = $result['error'] ?? 'failed';
json_out(['ok' => false, 'error' => $err], $err === 'forbidden' ? 403 : 400);
}
json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
}
if ($action === 'close_session') {
if (!Rbac::can('remote_access_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$sessionId = trim((string)($body['session_id'] ?? ''));
if ($sessionId === '') {
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
}
if (!RemoteAccessRepository::canOperatorCloseSession($sessionId, $userId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
json_out(['ok' => true]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);

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

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

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,167 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
Auth::check();
if (!Rbac::can('short_links_view')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = trim((string) ($_GET['action'] ?? ''));
$avail = ShortLinksRepository::availability();
if (!$avail['ok']) {
json_out([
'ok' => false,
'error' => 'service_unavailable',
'reason' => $avail['error'] ?? 'unknown',
], 503);
}
if ($method === 'GET') {
if ($action === 'dashboard') {
json_out([
'ok' => true,
'public_base' => UrlShortenerDatabase::publicBase(),
'bearers' => ShortLinksRepository::listBearers(),
'signers' => ShortLinksRepository::listSigners(),
'audit' => ShortLinksRepository::listAudit(40),
'permissions' => [
'can_operate' => Rbac::can('short_links_operate'),
'can_admin' => Rbac::isGlobalAdmin(),
],
]);
}
if ($action === 'links') {
$bearerId = (int) ($_GET['bearer_id'] ?? 0);
if ($bearerId <= 0) {
json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400);
}
json_out([
'ok' => true,
'links' => ShortLinksRepository::listLinks($bearerId),
]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
}
if ($action === 'mint_bearer') {
if (!Rbac::can('short_links_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$result = ShortLinksRepository::mintBearer(
(string) ($body['label'] ?? ''),
!array_key_exists('can_temporary', $body) || !empty($body['can_temporary']),
!empty($body['can_permanent']),
(int) ($body['rate_limit_per_hour'] ?? 1000),
!empty($body['mint_signer'])
);
if (empty($result['ok'])) {
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
}
$resp = [
'ok' => true,
'bearer_id' => $result['bearer_id'],
'token' => $result['token'],
];
if (isset($result['signer_key'])) {
$resp['signer_key'] = $result['signer_key'];
$resp['signer_id'] = $result['signer_id'];
}
json_out($resp);
}
if ($action === 'mint_signer') {
if (!Rbac::can('short_links_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$result = ShortLinksRepository::mintSigner((string) ($body['label'] ?? ''));
if (empty($result['ok'])) {
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
}
json_out([
'ok' => true,
'signer_id' => $result['id'],
'signer_key' => $result['key'],
]);
}
if ($action === 'revoke_signer') {
if (!Rbac::can('short_links_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$signerId = (int) ($body['signer_id'] ?? 0);
if ($signerId <= 0) {
json_out(['ok' => false, 'error' => 'missing_signer_id'], 400);
}
if (!ShortLinksRepository::revokeSigner($signerId)) {
json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404);
}
json_out(['ok' => true]);
}
if ($action === 'revoke_bearer') {
if (!Rbac::can('short_links_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$bearerId = (int) ($body['bearer_id'] ?? 0);
if ($bearerId <= 0) {
json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400);
}
if (!ShortLinksRepository::revokeBearer($bearerId)) {
json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404);
}
json_out(['ok' => true]);
}
if ($action === 'create_link') {
if (!Rbac::can('short_links_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$bearerId = (int) ($body['bearer_id'] ?? 0);
$url = (string) ($body['url'] ?? '');
$ttl = (int) ($body['ttl'] ?? 86400);
if ($bearerId <= 0 || trim($url) === '') {
json_out(['ok' => false, 'error' => 'missing_fields'], 400);
}
$result = ShortLinksRepository::createLink(
$bearerId,
$url,
$ttl,
(string) ($body['signer'] ?? '')
);
if (empty($result['ok'])) {
$code = match ($result['error'] ?? '') {
'rate_limited' => 429,
'ttl_expired' => 409,
'signer_required' => 403,
default => 400,
};
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed', 'message' => $result['message'] ?? ''], $code);
}
json_out(['ok' => true, 'link' => $result]);
}
if ($action === 'purge_expired') {
if (!Rbac::isGlobalAdmin()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$counts = ShortLinksRepository::purgeExpired();
json_out(['ok' => true, 'purged' => $counts]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);

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',
]);

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
try {
Database::requireTicketsTable();
} catch (RuntimeException $e) {
http_response_code(503);
echo 'Tickets unavailable';
exit;
}
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
exit;
}
$att = TicketAttachmentRepository::getById($id);
if (!$att || ($att['kind'] ?? '') !== 'file') {
http_response_code(404);
exit;
}
$path = TicketAttachmentRepository::filePath($att);
if ($path === null) {
http_response_code(404);
exit;
}
$name = (string) ($att['original_name'] ?? 'download');
$mime = (string) ($att['mime_type'] ?? 'application/octet-stream');
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string) filesize($path));
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
header('X-Content-Type-Options: nosniff');
readfile($path);

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
try {
Database::requireTicketsTable();
} catch (RuntimeException $e) {
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
}
$user = Auth::user();
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if ($method === 'GET') {
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
try {
json_out(['ok' => true, 'attachments' => TicketAttachmentRepository::listForTicket($ticketId)]);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_attachments') || str_contains($e->getMessage(), 'no such table')) {
json_out(['ok' => false, 'error' => 'attachments_table_missing', 'hint' => 'Run sql/migrations/005_ticket_attachments.sql'], 503);
}
throw $e;
}
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
try {
TicketAttachmentRepository::delete($id, (string) ($user['username'] ?? ''));
json_out(['ok' => true]);
} catch (RuntimeException $e) {
$code = $e->getMessage() === 'forbidden' ? 403 : 404;
json_out(['ok' => false, 'error' => $e->getMessage()], $code);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_attachments')) {
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
}
throw $e;
}
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
}
$ticketId = (int) ($_POST['ticket_id'] ?? 0);
if ($ticketId <= 0) {
$raw = file_get_contents('php://input');
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
if (is_array($data)) {
$ticketId = (int) ($data['ticket_id'] ?? 0);
}
}
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
// Any logged-in user who can view the ticket may add attachments (same as comments).
try {
if (!empty($_FILES['file']['name'])) {
$att = TicketAttachmentRepository::addFile(
$ticketId,
(string) ($user['username'] ?? ''),
$_FILES['file'],
trim((string) ($_POST['title'] ?? ''))
);
json_out(['ok' => true, 'attachment' => $att]);
}
$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' => 'file or url required'], 400);
}
$url = trim((string) ($data['url'] ?? $data['external_url'] ?? ''));
if ($url === '') {
json_out(['ok' => false, 'error' => 'url required'], 400);
}
$att = TicketAttachmentRepository::addLink(
$ticketId,
(string) ($user['username'] ?? ''),
$url,
trim((string) ($data['title'] ?? ''))
);
json_out(['ok' => true, 'attachment' => $att]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_attachments')) {
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
}
error_log('ticket_attachments: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
try {
Database::requireTicketsTable();
} catch (RuntimeException $e) {
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
}
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if ($method === 'GET') {
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
try {
json_out(['ok' => true, 'comments' => TicketCommentRepository::listForTicket($ticketId)]);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
}
throw $e;
}
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
}
$user = Auth::user();
$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);
}
$ticketId = (int) ($data['ticket_id'] ?? 0);
if ($ticketId <= 0) {
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
}
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit']) && !Auth::canEditTags()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
try {
$comment = TicketCommentRepository::add(
$ticketId,
(string) ($user['username'] ?? ''),
(string) ($data['body'] ?? '')
);
json_out(['ok' => true, 'comment' => $comment]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
}
error_log('ticket_comments: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
$raw = file_get_contents('php://input');
$data = json_decode($raw !== false ? $raw : '', true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
$user = Auth::user();
$title = trim((string) ($data['title'] ?? ''));
$body = trim((string) ($data['body'] ?? ''));
$brief = trim((string) ($data['brief'] ?? ''));
if ($title === '') {
json_out(['ok' => false, 'error' => 'title required'], 400);
}
if ($body === '' && $brief !== '') {
$body = $brief;
}
$tags = is_array($data['tags'] ?? null) ? $data['tags'] : [];
if ($tags === []) {
$tags = [['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']];
}
$now = (int) round(microtime(true) * 1000);
$payload = [
'schema_version' => 1,
'ingest_kind' => 'ticket',
'ticket_id' => 'web-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)),
'title' => mb_substr($title, 0, 256),
'brief' => $brief !== '' ? mb_substr($brief, 0, 512) : mb_substr($title, 0, 512),
'body' => $body !== '' ? $body : $title,
'opened_at_epoch_ms' => $now,
'rating' => max(0, min(5, (int) ($data['rating'] ?? 3))),
'owner' => (string) ($user['username'] ?? 'admin'),
'tags' => $tags,
'device' => [
'manufacturer' => 'web',
'model' => 'console',
'sdk_int' => 0,
'release' => 'n/a',
],
'app' => [
'package' => 'com.foxx.androidcast',
'version_name' => 'console',
'version_code' => 0,
],
];
try {
Database::requireTicketsTable();
$id = TicketRepository::insert($payload);
json_out(['ok' => true, 'id' => $id, 'ticket_id' => $payload['ticket_id']]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_create: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'create failed'], 500);
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
try {
Database::requireTicketsTable();
} catch (RuntimeException $e) {
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
}
$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);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
json_out([
'ok' => true,
'id' => $id,
'tags' => $ticket['tags'] ?? [],
'presets' => TicketRepository::listTagPresets(),
'can_edit' => !empty($ticket['can_edit']),
]);
}
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);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit'])) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$tagsIn = $data['tags'] ?? [];
if (!is_array($tagsIn)) {
json_out(['ok' => false, 'error' => 'tags must be array'], 400);
}
try {
TicketRepository::setCustomTags($id, $tagsIn);
$updated = TicketRepository::getById($id);
json_out([
'ok' => true,
'id' => $id,
'tags' => $updated['tags'] ?? [],
]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_tags: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
try {
Database::requireTicketsTable();
} catch (RuntimeException $e) {
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
}
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'PUT' && strtoupper($_SERVER['REQUEST_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'] ?? 0);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit'])) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$fields = [];
foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username', 'workflow_state'] as $key) {
if (array_key_exists($key, $data)) {
$fields[$key] = $data[$key];
}
}
if (array_key_exists('assignees', $data)) {
$fields['assignees'] = $data['assignees'];
}
try {
TicketRepository::update($id, $fields);
$updated = TicketRepository::getById($id);
json_out(['ok' => true, 'ticket' => $updated]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_update: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
json_out(['ok' => false, 'error' => 'empty 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);
}
if (($data['ingest_kind'] ?? 'ticket') !== 'ticket') {
json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400);
}
try {
Database::requireTicketsTable();
TicketRepository::insert($data);
json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (RuntimeException $e) {
error_log('ticket upload: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
} catch (PDOException $e) {
$msg = $e->getMessage();
if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
json_out(['ok' => false, 'error' => 'duplicate ticket_id'], 409);
}
error_log('ticket upload PDO: ' . $msg);
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $msg;
}
json_out($out, 500);
} catch (Throwable $e) {
error_log('ticket upload: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = (int) ($_GET['per_page'] ?? 50);
if (!in_array($perPage, [25, 50, 75, 100, 200], true)) {
$perPage = 50;
}
$sort = (string) ($_GET['sort'] ?? 'opened_at_ms');
$dir = (string) ($_GET['dir'] ?? 'desc');
$tag = trim((string) ($_GET['tag'] ?? ''));
try {
Database::requireTicketsTable();
$data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag);
json_out(['ok' => true] + $data);
} catch (RuntimeException $e) {
error_log('tickets api: ' . $e->getMessage());
$out = [
'ok' => false,
'error' => 'tickets_table_missing',
'hint' => $e->getMessage(),
];
if (cfg('debug')) {
try {
$pdo = Database::pdo();
$out['db_driver'] = Database::driver();
$out['tickets_table'] = Database::ticketsTableExists($pdo);
if (Database::isMysql()) {
$out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn();
}
} catch (Throwable $diag) {
$out['diag_error'] = $diag->getMessage();
}
}
json_out($out, 503);
} catch (Throwable $e) {
error_log('tickets api: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'list failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

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

View File

@@ -0,0 +1,9 @@
<?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, 'users' => UserRepository::listForAssignees()]);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
{
"app_name": "Android Cast Issues",
"nav.home": "Home",
"nav.reports": "Issues",
"nav.tickets": "Tickets",
"nav.logout": "Logout",
"nav.toggle": "Navigation",
"nav.home_desc": "Console home and workspace cards.",
"nav.reports_desc": "Browse and triage issues.",
"nav.tickets_desc": "Roadmap tasks, QA items, and tags.",
"nav.graphs": "Graphs",
"nav.graphs_desc": "Sessions, issues, and device activity.",
"nav.remote": "Remote access",
"nav.remote_desc": "WireGuard sessions and device reachability.",
"nav.access": "Access",
"nav.access_desc": "Roles, privilege sets, and operators.",
"nav.builder": "Builder",
"nav.builder_desc": "Docker CI for APK baking and OTA artifacts.",
"theme.label": "Theme",
"theme.dark": "Dark",
"theme.light": "Light",
"lang.label": "Language",
"lang.en": "English",
"lang.ru": "Russian",
"home.title": "Console",
"home.intro": "Issue triage, tickets, and session metrics — pick a workspace below.",
"home.card_issues": "Issues",
"home.card_issues_desc": "Browse and triage issues, tags, and fingerprints.",
"home.card_new_issue_desc": "Log a new issue or task in one step.",
"home.card_new_ticket_desc": "Create a roadmap or QA ticket in one step.",
"home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.",
"home.card_graphs": "Graphs",
"home.card_graphs_desc": "Sessions, issues, and device activity over time.",
"home.card_short_links": "Short links",
"home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.",
"home.more": "All services",
"reports.title": "Issues",
"reports.by_time": "By time",
"reports.grouped": "Grouped",
"reports.per_page": "Per page",
"reports.search": "Search",
"reports.search_placeholder": "Keywords (exceptions, devices, stack traces…)",
"reports.clear": "Clear",
"reports.loading": "Loading…",
"reports.empty": "No reports yet.",
"reports.status_showing": "Showing {from}{to} of {total}",
"reports.invalid_response": "Invalid response",
"reports.load_failed": "Load failed",
"reports.network_error": "Network error",
"reports.mode_disabled_filter": "Disabled while a filter is active",
"filter.banner_similar": "Similar reports for #{id}",
"filter.banner_device": "Device: “{value}”",
"filter.banner_abi": "ABI: {value}",
"filter.banner_sdk": "SDK {value}",
"filter.banner_os": "Android {value}",
"filter.banner_app": "App {value}",
"filter.banner_build": "Build {value}",
"filter.banner_search": "Search: “{value}”",
"filter.clear": "Clear filters",
"pagination.previous": "← Previous",
"pagination.next": "Next →",
"pagination.previous_range": "← Previous ({before}/{through})",
"pagination.next_range": "Next ({start}/{end}) →",
"col.device": "Device",
"col.app": "App",
"col.os": "OS",
"col.os_ver": "OS ver",
"col.received": "Received",
"col.rating": "Rating",
"col.count": "Count",
"col.fingerprint": "Fingerprint",
"col.type": "Type",
"col.last_generated": "Last generated",
"col.last_received": "Last received",
"col.tags": "Tags",
"col.expand": "Expand",
"col.drag": "Drag column",
"col.drag_reorder": "Drag column to reorder",
"graphs.brick_drag": "Drag chart to reorder",
"graphs.session_deficit": "Session charts are empty. On the device: Developer settings → enable “Grab session stats”, cast a session, then wait for upload to graph_upload.php.",
"row.show_summary": "Show summary",
"row.open_report": "Click to open full report",
"row.edit_tags": "Edit tags",
"tag.new": "new",
"tag.java": "Java",
"tag.ndk": "NDK",
"tag.android": "Android",
"tag.edit_title": "Edit tags",
"tag.close": "Close",
"tag.label": "Label",
"tag.color": "Color",
"tag.add": "Add",
"tag.add_tag": "Add tag",
"tag.save": "Save",
"tag.save_tags": "Save tags",
"tag.suggestions": "Suggestions:",
"tag.placeholder": "e.g. regression",
"tag.hint_auto": "Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.",
"tag.hint_admin": "Sign in as admin to edit custom tags.",
"tag.none_custom": "No custom tags yet.",
"tag.none_preview": "No custom tags",
"tag.remove": "Remove tag",
"tag.saving": "Saving…",
"tag.saved": "Saved",
"tag.save_failed": "Save failed",
"tag.workflow": "Workflow",
"tag.filter_label": "Filter by tag",
"tag.filter_mode": "Match",
"tag.filter_and": "All tags (AND)",
"tag.filter_or": "Any tag (OR)",
"detail.back": "Back to list",
"detail.title": "Issue report",
"detail.report_meta": "Report {id} · {type}",
"detail.tags": "Tags",
"detail.device": "Device",
"detail.app": "App",
"detail.timing": "Timing",
"detail.abis": "ABIs:",
"detail.build": "Build:",
"detail.generated": "Generated:",
"detail.received": "Received:",
"detail.fingerprint": "Fingerprint:",
"detail.filter_device": "Show reports for similar devices",
"detail.filter_os": "Show reports on this OS",
"detail.filter_version": "Show reports for this app version",
"detail.filter_build": "Show reports from this build",
"detail.java_exception": "Java exception",
"detail.native_crash": "Native issue",
"detail.find_similar": "Find similar issues",
"detail.thread": "Thread:",
"detail.signal": "Signal:",
"detail.session_context": "Session context (issue)",
"detail.raw_json": "Raw JSON",
"login.sign_in_hint": "Sign in to view anonymous issue reports",
"login.username": "Username",
"login.password": "Password",
"login.submit": "Sign in",
"login.hint_default": "Default: admin / admin",
"login.register": "Register",
"login.register_soon": "(coming soon)",
"login.error": "Invalid credentials",
"register.title": "Create account",
"register.hint": "We will email a verification link before you can sign in.",
"register.email": "Email",
"register.username": "Username (optional)",
"register.password": "Password",
"register.submit": "Register",
"register.back_login": "Back to sign in",
"verify.title": "Email verification",
"verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.",
"verify.fail": "Invalid or expired link.",
"verify.sign_in": "Sign in",
"verify.register_again": "Register again",
"twofa.title": "Two-factor authentication",
"twofa.hint": "Enter the 6-digit code from your authenticator app.",
"twofa.code": "Authentication code",
"twofa.submit": "Continue",
"twofa.cancel": "Cancel",
"twofa.error": "Invalid code",
"security.title": "Account security",
"security.totp_intro": "Protect your account with a 6-digit authenticator app (recommended for alpha).",
"security.start_totp": "Set up authenticator",
"security.scan_qr": "Scan this QR code with your authenticator app, then enter a code to confirm.",
"security.manual_secret": "Can't scan?",
"security.confirm_totp": "Confirm enrollment",
"security.totp_enabled": "Authenticator app is enrolled.",
"security.remove_totp": "Remove authenticator",
"security.back_console": "Back to console",
"nav.security": "Security",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"tickets.title": "Tickets",
"issues.new": "New issue",
"tickets.new": "New ticket",
"ticket.create": "Create",
"ticket.cancel": "Cancel",
"ticket.title": "Title",
"ticket.brief": "Brief",
"ticket.body": "Description",
"tickets.empty": "No tickets yet.",
"tickets.col_issue": "Issue",
"tickets.col_opened": "Opened",
"tickets.col_env": "App / OS",
"tickets.filter_tag": "Status tag",
"tickets.filter_all": "All",
"ticket.back": "Back to tickets",
"ticket.detail_title": "Ticket",
"ticket.id_label": "ID",
"ticket.tag_hint": "Must include ticket and a status tag (open, in progress, closed, …).",
"ticket.tag_readonly": "Only the owner or an admin can edit tags.",
"ticket.issue": "Issue",
"ticket.caption": "Caption",
"ticket.summary": "Summary",
"ticket.description": "Description",
"ticket.workflow": "Workflow",
"ticket.owner": "Owner",
"ticket.verified_by": "Verified by",
"ticket.timing": "Timing",
"ticket.opened": "Opened",
"ticket.created": "Created",
"ticket.updated": "Updated",
"ticket.save": "Save ticket",
"ticket.source": "Source / context",
"ticket.lifecycle": "Lifecycle",
"ticket.assignees": "Assignees",
"ticket.assignee_add": "Add assignee",
"ticket.comments": "Comments",
"ticket.comment_add": "Add comment",
"ticket.comment_post": "Post comment",
"ticket.attachments": "Attachments",
"ticket.attach_link": "Add link",
"ticket.attach_link_hint": "Google Docs, Sheets, Drive, Meet, or any HTTPS URL.",
"ticket.attach_link_btn": "Attach link",
"ticket.attach_file": "Upload file",
"ticket.attach_file_hint": "Max 16 MiB — images, logs, pdf, zip, …",
"ticket.attach_file_btn": "Upload",
"ticket.link_title": "Title (optional)",
"ticket.file_pick": "File"
}

View File

@@ -0,0 +1,196 @@
{
"app_name": "Сбои Android Cast",
"nav.home": "Главная",
"nav.reports": "Задачи (Issues)",
"nav.tickets": "Задачи",
"tickets.title": "Задачи",
"issues.new": "Новая проблема",
"tickets.new": "Новый тикет",
"ticket.create": "Создать",
"ticket.cancel": "Отмена",
"ticket.title": "Заголовок",
"ticket.brief": "Кратко",
"ticket.body": "Описание",
"tickets.empty": "Задач пока нет.",
"ticket.detail_title": "Задача",
"nav.logout": "Выход",
"nav.toggle": "Навигация",
"nav.home_desc": "Главная консоли и карточки разделов.",
"nav.reports_desc": "Просмотр и разбор issues.",
"nav.tickets_desc": "Задачи roadmap, QA и теги.",
"nav.graphs": "Графики",
"nav.graphs_desc": "Сессии, issues и активность устройств.",
"nav.remote": "Удалённый доступ",
"nav.remote_desc": "Сессии WireGuard и доступ к устройствам.",
"nav.access": "Доступ",
"nav.access_desc": "Роли, наборы привилегий и операторы.",
"nav.builder": "Сборщик",
"nav.builder_desc": "Docker CI для сборки APK и OTA.",
"theme.label": "Тема",
"theme.dark": "Тёмная",
"theme.light": "Светлая",
"lang.label": "Язык",
"lang.en": "English",
"lang.ru": "Русский",
"home.title": "Консоль",
"home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.",
"home.card_issues": "Проблемы",
"home.card_issues_desc": "Просмотр и разбор issues, тегов и отпечатков.",
"home.card_new_issue_desc": "Создать новую проблему или задачу.",
"home.card_new_ticket_desc": "Создать тикет roadmap или QA за один шаг.",
"home.card_tickets_desc": "Задачи, QA и теги workflow.",
"home.card_graphs": "Графики",
"home.card_graphs_desc": "Сессии, issues и активность устройств.",
"home.card_short_links": "Короткие ссылки",
"home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.",
"home.more": "Все сервисы",
"reports.title": "Issues",
"reports.by_time": "По времени",
"reports.grouped": "Группы",
"reports.per_page": "На странице",
"reports.search": "Поиск",
"reports.search_placeholder": "Ключевые слова (исключения, устройства, стеки…)",
"reports.clear": "Очистить",
"reports.loading": "Загрузка…",
"reports.empty": "Отчётов пока нет.",
"reports.status_showing": "Показано {from}{to} из {total}",
"reports.invalid_response": "Некорректный ответ",
"reports.load_failed": "Ошибка загрузки",
"reports.network_error": "Сетевая ошибка",
"reports.mode_disabled_filter": "Недоступно при активном фильтре",
"filter.banner_similar": "Похожие отчёты для №{id}",
"filter.banner_device": "Устройство: «{value}»",
"filter.banner_abi": "ABI: {value}",
"filter.banner_sdk": "SDK {value}",
"filter.banner_os": "Android {value}",
"filter.banner_app": "Приложение {value}",
"filter.banner_build": "Сборка {value}",
"filter.banner_search": "Поиск: «{value}»",
"filter.clear": "Сбросить фильтры",
"pagination.previous": "← Назад",
"pagination.next": "Вперёд →",
"pagination.previous_range": "← Назад ({before}/{through})",
"pagination.next_range": "Вперёд ({start}/{end}) →",
"col.device": "Устройство",
"col.app": "Приложение",
"col.os": "ОС",
"col.os_ver": "Версия ОС",
"col.received": "Получен",
"col.rating": "Рейтинг",
"col.count": "Кол-во",
"col.fingerprint": "Отпечаток",
"col.type": "Тип",
"col.last_generated": "Создан",
"col.last_received": "Получен",
"col.tags": "Метки",
"col.expand": "Развернуть",
"col.drag": "Перетащить столбец",
"col.drag_reorder": "Перетащите для смены порядка столбцов",
"graphs.brick_drag": "Перетащите график для смены порядка",
"graphs.session_deficit": "Графики сессий пусты. На устройстве: Настройки разработчика → «Grab session stats», проведите сессию трансляции и дождитесь загрузки в graph_upload.php.",
"row.show_summary": "Показать сводку",
"row.open_report": "Нажмите, чтобы открыть полный отчёт",
"row.edit_tags": "Редактировать метки",
"tag.new": "новый",
"tag.java": "Java",
"tag.ndk": "NDK",
"tag.android": "Android",
"tag.edit_title": "Редактирование меток",
"tag.close": "Закрыть",
"tag.label": "Название",
"tag.color": "Цвет",
"tag.add": "Добавить",
"tag.add_tag": "Добавить метку",
"tag.save": "Сохранить",
"tag.save_tags": "Сохранить метки",
"tag.suggestions": "Подсказки:",
"tag.hint_auto": "Автометки (NDK / Java / Android / новый) в списке. Ниже можно добавить свои.",
"tag.hint_admin": "Войдите как администратор, чтобы редактировать метки.",
"tag.none_custom": "Пользовательских меток пока нет.",
"tag.none_preview": "Нет пользовательских меток",
"tag.remove": "Удалить метку",
"tag.saving": "Сохранение…",
"tag.saved": "Сохранено",
"tag.save_failed": "Не удалось сохранить",
"tag.workflow": "Статус",
"tag.filter_label": "Фильтр по метке",
"tag.filter_mode": "Совпадение",
"tag.filter_and": "Все метки (И)",
"tag.filter_or": "Любая метка (ИЛИ)",
"detail.back": "К списку",
"detail.title": "Issue-отчёт",
"detail.report_meta": "Отчёт {id} · {type}",
"detail.tags": "Метки",
"detail.device": "Устройство",
"detail.app": "Приложение",
"detail.timing": "Время",
"detail.abis": "ABI:",
"detail.build": "Сборка:",
"detail.generated": "Создан:",
"detail.received": "Получен:",
"detail.fingerprint": "Отпечаток:",
"detail.filter_device": "Показать отчёты для похожих устройств",
"detail.filter_os": "Показать отчёты с этой ОС",
"detail.filter_version": "Показать отчёты для этой версии приложения",
"detail.filter_build": "Показать отчёты этой сборки",
"detail.java_exception": "Исключение Java",
"detail.native_crash": "Нативный issue",
"detail.find_similar": "Найти похожие отчёты",
"detail.thread": "Поток:",
"detail.signal": "Сигнал:",
"detail.session_context": "Контекст сессии (issue)",
"detail.raw_json": "Исходный JSON",
"login.sign_in_hint": "Войдите для просмотра анонимных issue-отчётов",
"login.username": "Имя пользователя",
"login.password": "Пароль",
"login.submit": "Войти",
"login.hint_default": "По умолчанию: admin / admin",
"login.register": "Регистрация",
"login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные",
"register.title": "Создать аккаунт",
"register.hint": "Мы отправим ссылку для подтверждения email перед входом.",
"register.email": "Email",
"register.username": "Имя пользователя (необязательно)",
"register.password": "Пароль",
"register.submit": "Зарегистрироваться",
"register.back_login": "Назад ко входу",
"verify.title": "Подтверждение email",
"verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.",
"verify.fail": "Недействительная или просроченная ссылка.",
"verify.sign_in": "Войти",
"verify.register_again": "Зарегистрироваться снова",
"twofa.title": "Двухфакторная аутентификация",
"twofa.hint": "Введите 6-значный код из приложения-аутентификатора.",
"twofa.code": "Код аутентификации",
"twofa.submit": "Продолжить",
"twofa.cancel": "Отмена",
"twofa.error": "Неверный код",
"security.title": "Безопасность аккаунта",
"security.totp_intro": "Защитите аккаунт приложением-аутентификатором (рекомендуется для альфы).",
"security.start_totp": "Настроить аутентификатор",
"security.scan_qr": "Отсканируйте QR-код и введите код для подтверждения.",
"security.manual_secret": "Не удаётся сканировать?",
"security.confirm_totp": "Подтвердить настройку",
"security.totp_enabled": "Приложение-аутентификатор подключено.",
"security.remove_totp": "Отключить аутентификатор",
"security.back_console": "Назад в консоль",
"nav.security": "Безопасность",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"nav.tickets": "Тикеты",
"ticket.lifecycle": "Жизненный цикл",
"ticket.assignees": "Исполнители",
"ticket.assignee_add": "Добавить",
"ticket.comments": "Комментарии",
"ticket.comment_add": "Новый комментарий",
"ticket.comment_post": "Отправить",
"ticket.attachments": "Вложения",
"ticket.attach_link": "Ссылка",
"ticket.attach_link_hint": "Google Docs, Таблицы, Drive, Meet или любой HTTPS URL.",
"ticket.attach_link_btn": "Прикрепить",
"ticket.attach_file": "Файл",
"ticket.attach_file_hint": "До 16 МБ — изображения, логи, pdf, zip…",
"ticket.attach_file_btn": "Загрузить",
"ticket.link_title": "Название (необяз.)",
"ticket.file_pick": "Файл"
}

View File

@@ -0,0 +1,157 @@
/**
* Google Analytics 4 for Android Cast web platform (hub + crash console).
* Set window.ANDROIDCAST_ANALYTICS before this script loads:
* { measurementId: 'G-XXXXXXXX', platform: 'hub'|'crashes', debug: false }
*/
(function (global) {
'use strict';
var cfg = global.ANDROIDCAST_ANALYTICS || {};
var measurementId = (cfg.measurementId || '').trim();
var platform = cfg.platform || 'web';
var debug = !!cfg.debug;
function log() {
if (debug && global.console) {
global.console.log.apply(global.console, ['[analytics]'].concat([].slice.call(arguments)));
}
}
function enabled() {
if (global.AndroidCastCookieConsent && !global.AndroidCastCookieConsent.allowsAnalytics()) {
var choice = global.AndroidCastCookieConsent.getChoice && global.AndroidCastCookieConsent.getChoice();
if (choice && choice !== 'all') {
return false;
}
}
return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId);
}
function ensureGtag(cb) {
if (!enabled()) {
return;
}
global.dataLayer = global.dataLayer || [];
if (!global.gtag) {
global.gtag = function () {
global.dataLayer.push(arguments);
};
}
if (global.__acAnalyticsLoaded) {
if (cb) cb();
return;
}
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(measurementId);
s.onload = function () {
global.__acAnalyticsLoaded = true;
global.gtag('js', new Date());
global.gtag('config', measurementId, {
send_page_view: false,
anonymize_ip: true
});
log('loaded', measurementId);
if (cb) cb();
};
document.head.appendChild(s);
}
function pagePath() {
var base = (cfg.basePath || '').replace(/\/$/, '');
var path = global.location.pathname || '/';
if (base && path.indexOf(base) === 0) {
path = path.slice(base.length) || '/';
}
return path;
}
function pageView(extra) {
if (!enabled()) return;
ensureGtag(function () {
var params = {
page_title: document.title || '',
page_location: global.location.href,
page_path: pagePath(),
platform: platform
};
if (extra) {
for (var k in extra) {
if (Object.prototype.hasOwnProperty.call(extra, k)) params[k] = extra[k];
}
}
global.gtag('event', 'page_view', params);
log('page_view', params);
});
}
function trackEvent(name, params) {
if (!enabled() || !name) return;
ensureGtag(function () {
var payload = params || {};
payload.platform = platform;
global.gtag('event', name, payload);
log('event', name, payload);
});
}
function bindConsoleView() {
var body = document.body;
if (!body) return;
var view = body.getAttribute('data-view');
if (view) {
trackEvent('console_view', { view_name: view });
}
}
function bindHubInteractions() {
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!(t instanceof Element)) return;
var card = t.closest('.hub-card, .hub-card-link');
if (card) {
var label = card.getAttribute('data-analytics-label')
|| (card.textContent || '').trim().slice(0, 64);
trackEvent('hub_nav_click', { link_text: label });
return;
}
if (t.closest('[data-open-docs], #hub-compass-layer, .hub-logo-layer--compass')) {
trackEvent('hub_open_docs', { source: 'compass' });
}
if (t.closest('[data-close-docs]')) {
trackEvent('hub_close_docs', {});
}
});
}
function init() {
if (!enabled()) {
log('disabled (no measurement id or cookie consent)');
return;
}
pageView();
if (platform === 'crashes' || String(platform).indexOf('live_') === 0) {
bindConsoleView();
} else if (platform === 'hub') {
bindHubInteractions();
}
}
global.addEventListener('ac-cookie-consent', function () {
if (enabled()) {
init();
}
});
global.AndroidCastAnalytics = {
enabled: enabled,
pageView: pageView,
trackEvent: trackEvent
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(typeof window !== 'undefined' ? window : this);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
/**
* Cookie consent banner — stores choice in localStorage (ac_cookie_consent).
* Values: all | necessary | reject
*/
(function (global) {
'use strict';
var STORAGE_KEY = 'ac_cookie_consent';
var banner = null;
function getChoice() {
try {
var v = localStorage.getItem(STORAGE_KEY);
if (v === 'all' || v === 'necessary' || v === 'reject') {
return v;
}
} catch (e) { /* ignore */ }
return null;
}
function setChoice(value) {
try {
localStorage.setItem(STORAGE_KEY, value);
} catch (e) { /* ignore */ }
global.dispatchEvent(new CustomEvent('ac-cookie-consent', { detail: { choice: value } }));
hideBanner();
}
function allowsAnalytics() {
return getChoice() === 'all';
}
function allowsPreferenceCookies() {
var c = getChoice();
return c === 'all' || c === 'necessary';
}
function hideBanner() {
if (banner) {
banner.hidden = true;
}
}
function ensureBanner() {
if (banner || getChoice()) {
return;
}
banner = document.getElementById('cookie-consent-banner');
if (!banner) {
return;
}
banner.hidden = false;
var allBtn = document.getElementById('cookie-consent-all');
var necBtn = document.getElementById('cookie-consent-necessary');
var rejBtn = document.getElementById('cookie-consent-reject');
if (allBtn) {
allBtn.addEventListener('click', function () { setChoice('all'); });
}
if (necBtn) {
necBtn.addEventListener('click', function () { setChoice('necessary'); });
}
if (rejBtn) {
rejBtn.addEventListener('click', function () { setChoice('reject'); });
}
}
global.AndroidCastCookieConsent = {
getChoice: getChoice,
allowsAnalytics: allowsAnalytics,
allowsPreferenceCookies: allowsPreferenceCookies,
setChoice: setChoice
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureBanner);
} else {
ensureBanner();
}
})(typeof window !== 'undefined' ? window : this);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
/**
* Crash console i18n — JSON catalogs (en default), same key style as Android strings.xml.
*/
(function (global) {
const STORAGE_KEY = 'crash_console_lang';
const SUPPORTED = ['en', 'ru'];
const DEFAULT_LANG = 'en';
/** @type {Record<string, { flag: string, code: string }>} */
const LANG_DISPLAY = {
en: { flag: '\uD83C\uDDEC\uD83C\uDDE7', code: 'EN' },
ru: { flag: '\uD83C\uDDF7\uD83C\uDDFA', code: 'RU' },
};
let lang = DEFAULT_LANG;
let catalog = {};
let fallback = {};
let readyResolve;
const ready = new Promise((resolve) => {
readyResolve = resolve;
});
function basePath() {
const body = document.body;
return body ? body.getAttribute('data-base-path') || '' : '';
}
/** Close stray modal layers so fixed backdrops never block login/console UI. */
function dismissPlatformOverlays() {
document.querySelectorAll('#graph-detail-overlay, #tag-modal, #hub-docs-modal').forEach((el) => {
el.hidden = true;
el.setAttribute('aria-hidden', 'true');
});
const dialog = document.getElementById('ticket-create-dialog');
if (dialog && dialog.open && typeof dialog.close === 'function') {
dialog.close();
}
}
function storedLang() {
try {
const v = localStorage.getItem(STORAGE_KEY);
return SUPPORTED.includes(v) ? v : DEFAULT_LANG;
} catch {
return DEFAULT_LANG;
}
}
function persistLang(code) {
try {
localStorage.setItem(STORAGE_KEY, code);
} catch { /* ignore */ }
}
function fetchCatalog(code) {
const url = basePath() + '/assets/i18n/' + encodeURIComponent(code) + '.json';
return fetch(url, { credentials: 'same-origin' }).then((res) => {
if (!res.ok) throw new Error('i18n load failed: ' + code);
return res.json();
});
}
/**
* @param {string} key
* @param {Record<string, string|number>|undefined} params
*/
function t(key, params) {
let s = catalog[key] ?? fallback[key] ?? key;
if (params) {
Object.keys(params).forEach((k) => {
s = s.split('{' + k + '}').join(String(params[k]));
});
}
return s;
}
function apply(root) {
const scope = root || document;
scope.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
if (!key) return;
if (el.hasAttribute('data-i18n-html')) {
el.innerHTML = t(key);
} else {
el.textContent = t(key);
}
});
scope.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
const key = el.getAttribute('data-i18n-placeholder');
if (key) el.setAttribute('placeholder', t(key));
});
scope.querySelectorAll('[data-i18n-title]').forEach((el) => {
const key = el.getAttribute('data-i18n-title');
if (key) el.setAttribute('title', t(key));
});
scope.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const key = el.getAttribute('data-i18n-aria');
if (key) el.setAttribute('aria-label', t(key));
});
scope.querySelectorAll('select[data-i18n-options]').forEach((sel) => {
const prefix = sel.getAttribute('data-i18n-options');
if (!prefix) return;
sel.querySelectorAll('option[data-i18n]').forEach((opt) => {
const k = opt.getAttribute('data-i18n');
if (k) opt.textContent = t(k);
});
});
scope.querySelectorAll('.platform-footer__left[data-i18n], footer[data-i18n="footer.copyright"]').forEach((footer) => {
const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
footer.textContent = t(footer.getAttribute('data-i18n') || 'footer.copyright', { year });
});
scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => {
el.textContent = t('detail.report_meta', {
id: el.getAttribute('data-i18n-id') || '',
type: el.getAttribute('data-i18n-type') || '',
});
});
}
function langOptionLabel(code) {
const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG];
return meta.flag + ' ' + meta.code;
}
function syncLangSelect() {
document.querySelectorAll('.lang-select').forEach((sel) => {
SUPPORTED.forEach((code) => {
const opt = sel.querySelector('option[value="' + code + '"]');
if (opt) opt.textContent = langOptionLabel(code);
});
if (sel.value !== lang) sel.value = lang;
});
syncLocaleDisplay(lang);
}
function syncLocaleDisplay(code) {
const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG];
document.querySelectorAll('.locale-flag').forEach((el) => {
el.textContent = meta.flag;
});
document.querySelectorAll('.locale-code').forEach((el) => {
el.textContent = meta.code;
});
}
async function setLang(code, options) {
const opts = options || {};
if (!SUPPORTED.includes(code)) code = DEFAULT_LANG;
fallback = await fetchCatalog(DEFAULT_LANG);
catalog = code === DEFAULT_LANG ? { ...fallback } : await fetchCatalog(code);
lang = code;
document.documentElement.setAttribute('lang', code);
persistLang(code);
syncLangSelect();
if (!opts.silent) {
apply(document);
document.dispatchEvent(new CustomEvent('crash-i18n-change', { detail: { lang: code } }));
}
}
function getLang() {
return lang;
}
function initLangSelect() {
document.querySelectorAll('.lang-select').forEach((sel) => {
sel.value = lang;
if (sel.dataset.i18nBound === '1') return;
sel.dataset.i18nBound = '1';
sel.addEventListener('change', () => {
setLang(sel.value).catch(() => setLang(DEFAULT_LANG));
});
});
}
async function init() {
dismissPlatformOverlays();
lang = storedLang();
document.documentElement.setAttribute('lang', lang);
await setLang(lang, { silent: true });
apply(document);
initLangSelect();
syncLocaleDisplay(lang);
readyResolve();
}
global.CrashI18n = {
t,
apply,
setLang,
getLang,
init,
ready,
SUPPORTED,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
init().catch(() => {
lang = DEFAULT_LANG;
readyResolve();
});
});
} else {
init().catch(() => readyResolve());
}
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,256 @@
(function (global) {
'use strict';
var STUN = [{ urls: 'stun:stun.l.google.com:19302' }];
function signalingUrl(basePath) {
return basePath + '/api/live_cast_signaling.php';
}
function castApiUrl(basePath) {
return basePath + '/api/live_cast.php';
}
function xhrJson(method, url, body, cb) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
if (body != null) {
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
}
xhr.onload = function () {
var payload = null;
try {
payload = JSON.parse(xhr.responseText);
} catch (e) {
cb(null);
return;
}
cb(payload);
};
xhr.onerror = function () { cb(null); };
xhr.send(body != null ? JSON.stringify(body) : null);
}
function postSignal(basePath, sessionId, peerRole, msgType, payload, cb) {
xhrJson('POST', signalingUrl(basePath), {
action: 'post',
session_id: sessionId,
peer_role: peerRole,
msg_type: msgType,
payload: payload
}, cb);
}
function pollSignals(basePath, sessionId, sinceId, fromRole, cb) {
var qs = '?action=poll&session_id=' + encodeURIComponent(sessionId)
+ '&since_id=' + encodeURIComponent(String(sinceId || 0));
if (fromRole) {
qs += '&from_role=' + encodeURIComponent(fromRole);
}
xhrJson('GET', signalingUrl(basePath) + qs, null, cb);
}
function collectStats(pc, videoEl) {
var stats = {
ice_state: pc ? pc.iceConnectionState : 'new',
signaling_state: pc ? pc.signalingState : 'stable',
fps: 0,
inbound_kbps: 0,
outbound_kbps: 0,
rtt_ms: 0,
frames_decoded: 0
};
if (videoEl && videoEl.videoWidth) {
stats.frame_w = videoEl.videoWidth;
stats.frame_h = videoEl.videoHeight;
}
return stats;
}
function enrichStatsFromRtc(pc, stats, cb) {
if (!pc || !pc.getStats) {
cb(stats);
return;
}
pc.getStats().then(function (report) {
report.forEach(function (r) {
if (r.type === 'inbound-rtp' && r.kind === 'video') {
stats.frames_decoded = r.framesDecoded || stats.frames_decoded;
if (r.bytesReceived && r.timestamp) {
stats.inbound_kbps = Math.round((r.bytesReceived * 8) / 1000);
}
}
if (r.type === 'outbound-rtp' && r.kind === 'video') {
if (r.bytesSent) {
stats.outbound_kbps = Math.round((r.bytesSent * 8) / 1000);
}
}
if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime) {
stats.rtt_ms = Math.round(r.currentRoundTripTime * 1000);
}
});
cb(stats);
}).catch(function () { cb(stats); });
}
function sendHeartbeat(basePath, sessionId, status, stats, cb) {
var body = { action: 'heartbeat', session_id: sessionId, status: status || 'active' };
if (stats) body.stats = stats;
xhrJson('POST', castApiUrl(basePath), body, cb || function () {});
}
/**
* @param {object} opts
* @param {string} opts.basePath
* @param {string} opts.sessionId
* @param {'caster'|'viewer'|'mobile'} opts.role
* @param {MediaStream|null} opts.localStream
* @param {function(MediaStream)} [opts.onRemoteStream]
* @param {function(object)} [opts.onStats]
* @param {function(string)} [opts.onStatus]
*/
function connect(opts) {
var basePath = opts.basePath || '';
var sessionId = opts.sessionId || '';
var role = opts.role || 'viewer';
var localStream = opts.localStream || null;
var onRemoteStream = opts.onRemoteStream || function () {};
var onStats = opts.onStats || function () {};
var onStatus = opts.onStatus || function () {};
var remoteRole = role === 'caster' ? 'viewer' : 'caster';
var sinceId = 0;
var pc = new RTCPeerConnection({ iceServers: STUN });
var pollTimer = null;
var hbTimer = null;
var remoteVideo = null;
var stopped = false;
function status(msg) {
onStatus(msg);
}
function teardown() {
stopped = true;
if (pollTimer) clearInterval(pollTimer);
if (hbTimer) clearInterval(hbTimer);
pollTimer = null;
hbTimer = null;
try { pc.close(); } catch (e) { /* ignore */ }
}
pc.onicecandidate = function (ev) {
if (!ev.candidate) return;
postSignal(basePath, sessionId, role, 'candidate', {
candidate: ev.candidate.candidate,
sdpMid: ev.candidate.sdpMid,
sdpMLineIndex: ev.candidate.sdpMLineIndex
}, function () {});
};
pc.ontrack = function (ev) {
var stream = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([ev.track]);
onRemoteStream(stream);
};
pc.oniceconnectionstatechange = function () {
status('ICE: ' + pc.iceConnectionState);
};
if (localStream) {
localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });
}
function applyRemoteDescription(payload, cb) {
if (!payload || !payload.type || !payload.sdp) {
cb(false);
return;
}
pc.setRemoteDescription(payload).then(function () {
cb(true);
}).catch(function () { cb(false); });
}
function handlePollMessages(messages) {
if (!messages || !messages.length) return;
messages.forEach(function (msg) {
sinceId = Math.max(sinceId, msg.id || 0);
var payload = msg.payload || {};
if (msg.msg_type === 'offer' && role === 'viewer') {
applyRemoteDescription(payload, function (ok) {
if (!ok) return;
pc.createAnswer().then(function (answer) {
return pc.setLocalDescription(answer);
}).then(function () {
postSignal(basePath, sessionId, role, 'answer', pc.localDescription, function () {});
status('Answer sent — connecting…');
}).catch(function () {
status('Could not create WebRTC answer.');
});
});
} else if (msg.msg_type === 'answer' && role === 'caster') {
applyRemoteDescription(payload, function (ok) {
if (ok) status('Viewer connected — streaming.');
});
} else if (msg.msg_type === 'candidate' && payload.candidate) {
pc.addIceCandidate({
candidate: payload.candidate,
sdpMid: payload.sdpMid,
sdpMLineIndex: payload.sdpMLineIndex
}).catch(function () {});
}
});
}
pollTimer = setInterval(function () {
if (stopped) return;
pollSignals(basePath, sessionId, sinceId, remoteRole, function (resp) {
if (!resp || !resp.ok) return;
handlePollMessages(resp.messages || []);
if (typeof resp.since_id === 'number') {
sinceId = Math.max(sinceId, resp.since_id);
}
});
}, 800);
hbTimer = setInterval(function () {
if (stopped) return;
enrichStatsFromRtc(pc, collectStats(pc, remoteVideo), function (stats) {
onStats(stats);
sendHeartbeat(basePath, sessionId, 'active', stats);
});
}, 5000);
if (role === 'caster') {
status('Publishing offer…');
pc.createOffer().then(function (offer) {
return pc.setLocalDescription(offer);
}).then(function () {
postSignal(basePath, sessionId, role, 'offer', pc.localDescription, function (resp) {
if (resp && resp.ok) {
status('Waiting for viewer…');
} else {
status('Signaling failed.');
}
});
}).catch(function () {
status('Could not create WebRTC offer.');
});
} else {
status('Waiting for caster offer…');
}
return {
setRemoteVideo: function (el) { remoteVideo = el; },
stop: function () {
sendHeartbeat(basePath, sessionId, 'ended', null);
teardown();
}
};
}
global.LiveCastWebRtc = {
connect: connect,
sendHeartbeat: sendHeartbeat,
collectStats: collectStats
};
})(typeof window !== 'undefined' ? window : this);

View File

@@ -0,0 +1,210 @@
(function () {
'use strict';
var MAX_DEMO_S = 300;
var timer = null;
var secondsLeft = MAX_DEMO_S;
var mediaStream = null;
var sessionId = '';
var webrtc = null;
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function el(id) {
return document.getElementById(id);
}
function detectPlatform() {
var ua = navigator.userAgent || '';
if (/Firefox\//i.test(ua)) return 'firefox';
if (/Edg\//i.test(ua)) return 'edge';
if (/Chrome\//i.test(ua)) return 'chrome';
if (/Safari\//i.test(ua)) return 'safari';
return 'desktop';
}
function apiUrl() {
return basePath() + '/api/live_cast.php';
}
function postJson(body, cb) {
var xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl(), true);
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
xhr.onload = function () {
var payload = null;
try {
payload = JSON.parse(xhr.responseText);
} catch (e) {
cb(null);
return;
}
cb(payload);
};
xhr.onerror = function () { cb(null); };
xhr.send(JSON.stringify(body));
}
function setStatus(text) {
var node = el('edu-status');
if (node) node.textContent = text;
}
function updateTimer() {
var node = el('edu-timer');
if (!node) return;
var m = Math.floor(secondsLeft / 60);
var s = secondsLeft % 60;
node.textContent = m + ':' + (s < 10 ? '0' : '') + s;
}
function renderShare(session) {
var box = el('edu-share');
if (!box || !session) return;
var joinUrl = session.join_short_url || '';
var qrUrl = session.join_qr_url || '';
var longJoin = basePath() + '/live/join?session_id=' + encodeURIComponent(session.session_id || sessionId);
if (!joinUrl) joinUrl = longJoin;
var html = '<h2>Share this demo</h2><p class="muted">Viewers open this link in another browser (same LAN or online).</p>';
html += '<p><a href="' + joinUrl + '" target="_blank" rel="noopener">' + joinUrl + '</a></p>';
if (qrUrl) {
html += '<figure class="twofa-qr-wrap"><img src="' + qrUrl + '" width="160" height="160" alt="Viewer QR code"><figcaption class="muted">Scan to open viewer page</figcaption></figure>';
}
box.hidden = false;
box.innerHTML = html;
}
function stopDemo() {
if (timer) {
clearInterval(timer);
timer = null;
}
if (webrtc) {
webrtc.stop();
webrtc = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach(function (t) { t.stop(); });
mediaStream = null;
}
var preview = el('edu-preview');
if (preview) preview.srcObject = null;
if (sessionId) {
postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {});
sessionId = '';
}
var share = el('edu-share');
if (share) share.hidden = true;
el('edu-start').disabled = false;
el('edu-stop').disabled = true;
setStatus('Demo ended. Thanks for trying AndroidCast education mode.');
}
function startWebRtc() {
if (!window.LiveCastWebRtc || !sessionId || !mediaStream) return;
webrtc = window.LiveCastWebRtc.connect({
basePath: basePath(),
sessionId: sessionId,
role: 'caster',
localStream: mediaStream,
onStatus: setStatus,
onStats: function (stats) {
var nerds = el('edu-stats');
if (!nerds) return;
nerds.hidden = false;
nerds.textContent = 'Stats: ICE ' + (stats.ice_state || '—')
+ ' · out ' + (stats.outbound_kbps || 0) + ' kbps'
+ ' · RTT ' + (stats.rtt_ms || 0) + ' ms';
}
});
}
function startDemo() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
setStatus('Screen sharing is not supported in this browser.');
return;
}
if (!window.RTCPeerConnection) {
setStatus('WebRTC is not available in this browser.');
return;
}
setStatus('Requesting screen share permission…');
navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
.then(function (stream) {
mediaStream = stream;
var preview = el('edu-preview');
if (preview) {
preview.hidden = false;
preview.srcObject = stream;
preview.play().catch(function () {});
}
stream.getVideoTracks()[0].addEventListener('ended', stopDemo);
setStatus('Creating demo session…');
postJson({
action: 'create_intent',
platform: 'education_' + detectPlatform(),
max_duration_s: MAX_DEMO_S,
codec_video: 'browser_screen',
codec_audio: 'none',
bw_mode: 'demo'
}, function (resp) {
if (!resp || !resp.ok || !resp.session) {
setStatus('Could not register demo session. Try again later.');
stopDemo();
return;
}
sessionId = resp.session.session_id || '';
renderShare(resp.session);
startWebRtc();
secondsLeft = MAX_DEMO_S;
updateTimer();
el('edu-start').disabled = true;
el('edu-stop').disabled = false;
setStatus('Live demo — share the link so others can watch.');
timer = setInterval(function () {
secondsLeft -= 1;
updateTimer();
if (secondsLeft <= 0) {
stopDemo();
}
}, 1000);
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
window.AndroidCastAnalytics.trackEvent('live_education_start', { platform: detectPlatform() });
}
});
})
.catch(function () {
setStatus('Screen share permission denied or cancelled.');
});
}
function boot() {
if (!el('live-education-app')) return;
updateTimer();
var startBtn = el('edu-start');
var stopBtn = el('edu-stop');
if (startBtn) startBtn.addEventListener('click', startDemo);
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.addEventListener('click', stopDemo);
}
var notifBtn = el('edu-notify');
if (notifBtn && 'Notification' in window) {
notifBtn.addEventListener('click', function () {
Notification.requestPermission().then(function (perm) {
setStatus(perm === 'granted' ? 'Notifications enabled.' : 'Notifications not granted.');
});
});
} else if (notifBtn) {
notifBtn.hidden = true;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

View File

@@ -0,0 +1,224 @@
(function () {
'use strict';
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function el(id) {
return document.getElementById(id);
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function detectPlatform() {
var ua = navigator.userAgent || '';
if (/Android/i.test(ua)) return 'android';
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
if (/Firefox\//i.test(ua)) return 'firefox';
if (/Edg\//i.test(ua)) return 'edge';
if (/Chrome\//i.test(ua)) return 'chrome';
if (/Safari\//i.test(ua)) return 'safari';
return 'desktop';
}
function anonId() {
var key = 'ac_live_anon_id';
try {
var existing = localStorage.getItem(key);
if (existing) return existing;
var id = 'anon-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
localStorage.setItem(key, id);
return id;
} catch (e) {
return 'anon-' + Date.now();
}
}
function apiUrl() {
return basePath() + '/api/live_cast.php';
}
function postJoinEvent(sessionId, eventType, direct) {
var xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl(), true);
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
xhr.send(JSON.stringify({
action: 'join_event',
session_id: sessionId,
event_type: eventType,
platform: detectPlatform(),
channel: direct ? 'direct_link' : 'viewer_link',
direct: !!direct,
anon_id: anonId()
}));
}
function fetchSession(sessionId, cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', apiUrl() + '?action=session&session_id=' + encodeURIComponent(sessionId), true);
xhr.onload = function () {
var payload = null;
try {
payload = JSON.parse(xhr.responseText);
} catch (e) {
cb(null);
return;
}
cb(payload && payload.ok ? payload.session : null);
};
xhr.onerror = function () { cb(null); };
xhr.send();
}
function formatStatus(session) {
if (!session) return 'Unknown';
var st = session.status || '';
if (st === 'live' || st === 'intent') return 'Live';
if (st === 'closed') return 'Ended';
if (st === 'expired') return 'Expired';
return st;
}
function renderStats(statsEl, stats) {
if (!statsEl || !stats) return;
statsEl.hidden = false;
statsEl.innerHTML =
'<strong>Statistics for nerds</strong><br>'
+ 'ICE: ' + escapeHtml(stats.ice_state || '—')
+ ' · sig: ' + escapeHtml(stats.signaling_state || '—')
+ '<br>In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps'
+ '<br>RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0)
+ (stats.frame_w ? '<br>Frame: ' + stats.frame_w + '×' + stats.frame_h : '');
}
function startViewer(sessionId, session, playerEl, statusEl, statsEl) {
if (!window.RTCPeerConnection || !window.LiveCastWebRtc) {
playerEl.innerHTML = '<p class="muted">WebRTC is not available in this browser.</p>';
return null;
}
playerEl.innerHTML =
'<video id="live-remote-video" class="live-preview" playsinline autoplay controls></video>'
+ '<div id="live-join-stats" class="live-stats-nerds" hidden></div>';
var video = el('live-remote-video');
var nerds = el('live-join-stats');
var conn = window.LiveCastWebRtc.connect({
basePath: basePath(),
sessionId: sessionId,
role: 'viewer',
onStatus: function (msg) {
statusEl.textContent = formatStatus(session) + ' — ' + msg;
},
onRemoteStream: function (stream) {
if (video) {
video.srcObject = stream;
video.play().catch(function () {});
}
statusEl.textContent = formatStatus(session) + ' — playing stream';
},
onStats: function (stats) {
renderStats(nerds || statsEl, stats);
}
});
if (conn && video) conn.setRemoteVideo(video);
return conn;
}
function boot() {
var root = el('live-join-app');
if (!root) return;
var params = new URLSearchParams(window.location.search);
var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || '';
var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1';
var role = params.get('role') || (direct ? 'direct' : 'viewer');
var webrtcConn = null;
if (!sessionId) {
el('live-join-status').textContent = 'Missing session_id in URL.';
return;
}
postJoinEvent(sessionId, 'open', direct);
var statusEl = el('live-join-status');
var metaEl = el('live-join-meta');
var playerEl = el('live-join-player');
var statsEl = el('live-join-stats-panel');
function render(session) {
if (!session) {
statusEl.textContent = 'Session not found or no longer available.';
return;
}
var live = session.status === 'live' || session.status === 'intent';
statusEl.textContent = formatStatus(session) + (live ? ' — connecting…' : '');
var owner = session.owner_username || 'host';
var lines = [
'Host: ' + owner,
'Platform: ' + (session.platform || '—'),
'Video: ' + (session.codec_video || '—'),
'Audio: ' + (session.codec_audio || '—'),
'Bandwidth: ' + (session.bw_mode || '—')
];
if (session.join_short_url) {
lines.push('Viewer link: ' + session.join_short_url);
}
metaEl.innerHTML = lines.map(function (l) {
return '<li>' + escapeHtml(l) + '</li>';
}).join('');
if (direct && /android|ios/i.test(detectPlatform())) {
playerEl.innerHTML =
'<p class="muted">Open in the AndroidCast app for direct P2P on LAN.</p>' +
'<p><code>androidcast://join?session_id=' + escapeHtml(sessionId) + '&amp;direct=1</code></p>' +
'<p class="muted">Mobile WebRTC signaling uses the same REST API when the app transport is wired.</p>';
postJoinEvent(sessionId, 'join', true);
} else if (live) {
webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl);
postJoinEvent(sessionId, 'join', false);
} else {
playerEl.innerHTML = '<p class="muted">Session is not live.</p>';
}
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
window.AndroidCastAnalytics.trackEvent('live_join_view', {
session_id: sessionId,
direct: direct ? 1 : 0,
platform: detectPlatform(),
status: session.status || ''
});
}
}
fetchSession(sessionId, render);
setInterval(function () {
fetchSession(sessionId, function (session) {
if (!session) return;
if (!(session.status === 'live' || session.status === 'intent')) {
statusEl.textContent = formatStatus(session);
if (webrtcConn) {
webrtcConn.stop();
webrtcConn = null;
}
}
});
}, 30000);
window.addEventListener('beforeunload', function () {
if (webrtcConn) webrtcConn.stop();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

View File

@@ -0,0 +1,113 @@
(function () {
'use strict';
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function el(id) {
return document.getElementById(id);
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatMs(ms) {
if (!ms) return '—';
try {
return new Date(ms).toLocaleString();
} catch (e) {
return String(ms);
}
}
function durationS(startMs, endMs) {
if (!startMs) return '—';
var end = endMs || Date.now();
var sec = Math.max(0, Math.round((end - startMs) / 1000));
if (sec < 60) return sec + 's';
return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
}
function statusClass(status) {
if (status === 'live' || status === 'intent') return 'tag-pill tag-pill--ok';
if (status === 'expired') return 'tag-pill tag-pill--warn';
return 'tag-pill';
}
function renderRows(sessions) {
var tbody = el('live-sessions-tbody');
if (!tbody) return;
if (!sessions || !sessions.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">No sessions in this window.</td></tr>';
return;
}
tbody.innerHTML = sessions.map(function (s) {
var joinUrl = s.join_short_url || '';
var joinCell = joinUrl
? '<a href="' + escapeHtml(joinUrl) + '" target="_blank" rel="noopener">Open</a>'
: '—';
return (
'<tr>' +
'<td><span class="' + statusClass(s.status) + '">' + escapeHtml(s.status) + '</span></td>' +
'<td>' + escapeHtml(s.owner_username || '—') + '</td>' +
'<td>' + escapeHtml(s.platform || '—') + '</td>' +
'<td>' + escapeHtml(s.codec_video || '—') + '</td>' +
'<td>' + durationS(s.started_at_ms, s.ended_at_ms) + '</td>' +
'<td>' + formatMs(s.last_heartbeat_ms) + '</td>' +
'<td>' + Number(s.join_opens || 0) + '</td>' +
'<td>' + joinCell + '</td>' +
'</tr>'
);
}).join('');
}
function load() {
var status = el('live-sessions-status');
var daysSel = el('live-sessions-days');
var days = daysSel && daysSel.value ? daysSel.value : '14';
if (status) status.textContent = 'Loading…';
var xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/live_cast.php?action=list&days=' + encodeURIComponent(days), true);
xhr.onload = function () {
var payload = null;
try {
payload = JSON.parse(xhr.responseText);
} catch (e) {
if (status) status.textContent = 'Invalid response';
return;
}
if (!payload || !payload.ok) {
if (status) status.textContent = (payload && payload.error) || 'Failed';
return;
}
renderRows(payload.sessions || []);
if (status) {
status.textContent = 'Loaded ' + (payload.sessions || []).length + ' session(s) · ' + days + 'd window';
}
};
xhr.onerror = function () {
if (status) status.textContent = 'Network error';
};
xhr.send();
}
function boot() {
if (!el('live-sessions-app')) return;
var daysSel = el('live-sessions-days');
if (daysSel) daysSel.addEventListener('change', load);
load();
setInterval(load, 60000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

View File

@@ -0,0 +1,83 @@
/**
* Collapsible left nav: click toggle + horizontal drag on handle (open on drag right).
*/
(function () {
'use strict';
var DRAG_THRESHOLD_PX = 20;
function setOpen(nav, handle, open) {
nav.classList.toggle('open', open);
if (handle) {
handle.setAttribute('aria-expanded', open ? 'true' : 'false');
}
}
function bindNavShell(paneId, handleId) {
var nav = document.getElementById(paneId);
var handle = document.getElementById(handleId);
if (!nav || !handle) return;
setOpen(nav, handle, nav.classList.contains('open'));
var drag = null;
var suppressClick = false;
handle.addEventListener('click', function (ev) {
if (suppressClick) {
suppressClick = false;
ev.preventDefault();
return;
}
setOpen(nav, handle, !nav.classList.contains('open'));
});
handle.addEventListener('pointerdown', function (ev) {
if (ev.button !== 0) return;
drag = { x: ev.clientX, moved: false };
handle.setPointerCapture(ev.pointerId);
handle.classList.add('is-dragging');
ev.preventDefault();
});
handle.addEventListener('pointermove', function (ev) {
if (!drag || !handle.hasPointerCapture(ev.pointerId)) return;
var dx = ev.clientX - drag.x;
if (Math.abs(dx) < 4) return;
drag.moved = true;
if (dx > DRAG_THRESHOLD_PX) {
setOpen(nav, handle, true);
drag.x = ev.clientX;
} else if (dx < -DRAG_THRESHOLD_PX) {
setOpen(nav, handle, false);
drag.x = ev.clientX;
}
});
function endDrag(ev) {
if (!drag) return;
if (drag.moved) suppressClick = true;
drag = null;
handle.classList.remove('is-dragging');
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
}
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
}
window.initNavShell = bindNavShell;
function autoInit() {
bindNavShell('nav-pane', 'nav-handle');
bindNavShell('landing-nav-pane', 'landing-nav-handle');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
}
})();

View File

@@ -0,0 +1,196 @@
(function () {
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function canEditGlobal() {
return document.body.getAttribute('data-can-rbac-root') === '1';
}
function setStatus(msg, isError) {
const el = document.getElementById('rbac-status');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isError);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || 'HTTP ' + res.status);
}
return data;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function renderPanel(data) {
const tbody = document.getElementById('rbac-users-tbody');
if (!tbody) return;
tbody.innerHTML = '';
const companies = data.companies || [];
const companyRoles = data.company_roles || [];
const globalRoles = data.global_roles || [];
const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []);
const seenAuth = new Set();
(data.users || []).forEach((u) => {
(u.memberships || []).forEach((m) => {
const tr = document.createElement('tr');
const globalSelect = canEditGlobal()
? '<select class="rbac-global-role" data-user-id="' + u.id + '">' +
globalRoles.map((r) => '<option value="' + esc(r) + '"' + (u.global_role === r ? ' selected' : '') + '>' + esc(r) + '</option>').join('') +
'</select>'
: '<code>' + esc(u.global_role) + '</code>';
const roleSelect =
'<select class="rbac-company-role" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
companyRoles
.map((r) => '<option value="' + esc(r) + '"' + (m.role === r ? ' selected' : '') + '>' + esc(r) + '</option>')
.join('') +
'</select>';
const setSelect =
'<select class="rbac-privilege-set" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
sets
.map((s) => {
const key = s.key != null ? s.key : '';
const label = s.label || key || '(none)';
const sel = (m.privilege_set || '') === key ? ' selected' : '';
return '<option value="' + esc(key) + '"' + sel + '>' + esc(label) + '</option>';
})
.join('') +
(m.privilege_set === 'custom' ? '<option value="" disabled>custom grants (edit via API)</option>' : '') +
'</select>';
let authCell = '';
if (!seenAuth.has(u.id)) {
seenAuth.add(u.id);
const fails = Number(u.auth_failures || 0);
if (canEditGlobal() && fails > 0) {
authCell =
'<span class="muted">' + fails + ' fail</span> ' +
'<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts</button>';
} else if (fails > 0) {
authCell = '<span class="muted">' + fails + ' recent fail</span>';
} else {
authCell = '<span class="muted">—</span>';
}
}
tr.innerHTML =
'<td>' + esc(u.username) + '</td>' +
'<td>' + globalSelect + '</td>' +
'<td><code>' + esc(m.slug) + '</code> ' + esc(m.name) + '</td>' +
'<td>' + roleSelect + '</td>' +
'<td>' + setSelect + '</td>' +
'<td>' + authCell + '</td>';
tbody.appendChild(tr);
});
if (!(u.memberships || []).length && canEditGlobal()) {
const tr = document.createElement('tr');
const fails = Number(u.auth_failures || 0);
let authCell = fails > 0
? '<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts (' + fails + ')</button>'
: '<span class="muted">—</span>';
tr.innerHTML =
'<td>' + esc(u.username) + '</td>' +
'<td><code>' + esc(u.global_role) + '</code></td>' +
'<td colspan="3" class="muted">No company membership</td>' +
'<td>' + authCell + '</td>';
tbody.appendChild(tr);
}
});
if (!tbody.children.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">No users in scope.</td></tr>';
}
const hint = document.getElementById('rbac-scope-hint');
if (hint) {
hint.textContent =
'Slug admin = company owner/admin (graphs company scope). Root may change global roles. Privilege sets add remote-access grants to members.';
if (companies.length) {
hint.textContent += ' Companies: ' + companies.map((c) => c.slug).join(', ');
}
}
}
async function postAction(action, body) {
await fetchJson(basePath() + '/api/rbac.php?action=' + encodeURIComponent(action), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function bindChanges() {
document.addEventListener('click', async (ev) => {
const btn = ev.target && ev.target.closest ? ev.target.closest('.rbac-clear-auth') : null;
if (!btn || !canEditGlobal()) return;
const userId = Number(btn.getAttribute('data-user-id'));
if (!userId) return;
try {
await postAction('clear_auth_lockouts', { user_id: userId });
setStatus('Auth lockouts cleared.');
load();
} catch (e) {
setStatus(String(e.message || e), true);
}
});
document.addEventListener('change', async (ev) => {
const t = ev.target;
if (!t || !t.classList) return;
try {
if (t.classList.contains('rbac-global-role') && canEditGlobal()) {
await postAction('set_global_role', {
user_id: Number(t.getAttribute('data-user-id')),
role: t.value,
});
setStatus('Global role updated.');
return;
}
if (t.classList.contains('rbac-company-role')) {
await postAction('set_company_role', {
user_id: Number(t.getAttribute('data-user-id')),
company_id: Number(t.getAttribute('data-company-id')),
role: t.value,
});
setStatus('Company role updated.');
return;
}
if (t.classList.contains('rbac-privilege-set')) {
await postAction('apply_privilege_set', {
user_id: Number(t.getAttribute('data-user-id')),
company_id: Number(t.getAttribute('data-company-id')),
privilege_set: t.value,
});
setStatus('Privilege set applied.');
}
} catch (e) {
setStatus(String(e.message || e), true);
}
});
}
async function load() {
setStatus('Loading…');
try {
const data = await fetchJson(basePath() + '/api/rbac.php?action=panel');
renderPanel(data);
setStatus('Ready — changes save on selection.');
} catch (e) {
setStatus(String(e.message || e), true);
}
}
document.addEventListener('DOMContentLoaded', function () {
bindChanges();
load();
});
})();

View File

@@ -0,0 +1,801 @@
(function () {
const STORAGE = {
colWidths: 'ra_console_col_widths',
colOrder: 'ra_console_col_order',
};
const RA_COLUMNS = [
{ key: 'device_name', label: 'Device' },
{ key: 'status', label: 'Status' },
{ key: 'opt_in', label: 'Opt-in' },
{ key: 'android_api', label: 'Android API' },
{ key: 'android_version', label: 'Android version' },
{ key: 'last_seen', label: 'Last seen' },
{ key: 'app_version', label: 'App' },
];
const DEFAULT_COL_WIDTHS = {
expand: 40,
device_name: 160,
status: 120,
opt_in: 88,
android_api: 96,
android_version: 112,
last_seen: 152,
app_version: 80,
actions: 220,
};
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function canOperate() {
return document.body.getAttribute('data-can-ra-operate') === '1';
}
function canAdmin() {
return document.body.getAttribute('data-can-ra-admin') === '1';
}
function lsGet(key, fallback) {
try {
const v = localStorage.getItem(key);
return v === null ? fallback : v;
} catch {
return fallback;
}
}
function lsSet(key, value) {
try {
localStorage.setItem(key, value);
} catch { /* ignore */ }
}
function apiUrl(action, params) {
const q = new URLSearchParams(params || {});
q.set('action', action);
return basePath() + '/api/remote_access.php?' + q.toString();
}
function setStatus(msg, isError) {
const el = document.getElementById('ra-status');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isError);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || ('HTTP ' + res.status));
}
return data;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function formatBytes(n) {
const b = Number(n) || 0;
if (b < 1024) return b + ' B';
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KiB';
if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(2) + ' MiB';
return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GiB';
}
function getColWidths() {
try {
const w = JSON.parse(lsGet(STORAGE.colWidths, '{}'));
return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) };
} catch {
return { ...DEFAULT_COL_WIDTHS };
}
}
function saveColWidths(widths) {
lsSet(STORAGE.colWidths, JSON.stringify(widths));
}
function getOrderedColumnKeys() {
const defaults = RA_COLUMNS.map((c) => c.key);
try {
const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]'));
if (Array.isArray(saved) && saved.length) {
const out = saved.filter((k) => defaults.includes(k));
defaults.forEach((k) => {
if (!out.includes(k)) out.push(k);
});
return out;
}
} catch { /* ignore */ }
return defaults;
}
function saveColumnOrder(keys) {
lsSet(STORAGE.colOrder, JSON.stringify(keys));
}
function getDisplayColumns() {
return getOrderedColumnKeys()
.map((k) => RA_COLUMNS.find((c) => c.key === k))
.filter(Boolean);
}
function columnLabelByKey(key) {
const c = RA_COLUMNS.find((col) => col.key === key);
if (c) return c.label;
if (key === 'actions') return 'Actions';
return key;
}
function colspan() {
return getDisplayColumns().length + 2;
}
function applyColgroup() {
const cg = document.getElementById('ra-devices-colgroup');
if (!cg) return;
const widths = getColWidths();
const order = getOrderedColumnKeys();
let html =
'<col class="col-expand" style="width:' + (widths.expand || 40) + 'px" />';
order.forEach((k) => {
html +=
'<col data-col="' +
esc(k) +
'" style="width:' +
(widths[k] || 100) +
'px" />';
});
html +=
'<col class="col-actions" style="width:' + (widths.actions || 220) + 'px" />';
cg.innerHTML = html;
}
function clearColumnDropMarkers(thead) {
thead.querySelectorAll('.th-drop-target').forEach((el) => {
el.classList.remove('th-drop-target');
});
thead.querySelectorAll('.th-dragging').forEach((el) => {
el.classList.remove('th-dragging');
});
}
function renderHead() {
const thead = document.getElementById('ra-devices-thead');
if (!thead) return;
const cols = getDisplayColumns();
const widths = getColWidths();
let html =
'<tr><th class="report-tree-head" aria-label="Expand"><span class="col-resizer" data-resize-col="expand"></span></th>';
cols.forEach((c) => {
const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : '';
html +=
'<th class="th-draggable"' +
' draggable="true" data-col-key="' +
esc(c.key) +
'" title="Drag to reorder"' +
w +
' scope="col"><span class="th-inner">' +
'<span class="th-drag" aria-hidden="true" title="Drag column">⋮⋮</span>' +
'<span class="th-label">' +
esc(c.label) +
'</span></span>' +
'<span class="col-resizer" data-resize-col="' +
esc(c.key) +
'"></span></th>';
});
html +=
'<th class="col-actions" scope="col" style="width:' +
(widths.actions || 220) +
'px">Actions<span class="col-resizer" data-resize-col="actions"></span></th></tr>';
thead.innerHTML = html;
}
function bindTableLayout() {
const table = document.getElementById('ra-devices-table');
const thead = document.getElementById('ra-devices-thead');
if (!table || !thead || table.dataset.layoutBound === '1') return;
table.dataset.layoutBound = '1';
thead.addEventListener('dragstart', (e) => {
if (e.target.closest('.col-resizer')) {
e.preventDefault();
return;
}
const th = e.target.closest('th.th-draggable[data-col-key]');
if (!th) return;
const sourceKey = th.getAttribute('data-col-key');
if (!sourceKey) return;
table.dataset.dragColKey = sourceKey;
th.classList.add('th-dragging');
const ghost = document.createElement('div');
ghost.className = 'col-drag-ghost';
ghost.textContent = columnLabelByKey(sourceKey);
ghost.setAttribute('aria-hidden', 'true');
document.body.appendChild(ghost);
table._dragGhost = ghost;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', sourceKey);
try {
e.dataTransfer.setDragImage(ghost, 16, 14);
} catch { /* ignore */ }
}
});
thead.addEventListener('dragend', () => {
clearColumnDropMarkers(thead);
delete table.dataset.dragColKey;
if (table._dragGhost) {
table._dragGhost.remove();
table._dragGhost = null;
}
});
thead.addEventListener('dragenter', (e) => {
if (!table.dataset.dragColKey) return;
e.preventDefault();
});
thead.addEventListener('dragover', (e) => {
if (!table.dataset.dragColKey) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const th = e.target.closest('th[data-col-key]');
if (!th) return;
const active = thead.querySelector('.th-drop-target');
if (active && active !== th) active.classList.remove('th-drop-target');
th.classList.add('th-drop-target');
});
thead.addEventListener('dragleave', (e) => {
const th = e.target.closest('th[data-col-key]');
if (!th) return;
const rel = e.relatedTarget;
if (rel && th.contains(rel)) return;
th.classList.remove('th-drop-target');
});
thead.addEventListener('drop', (e) => {
const th = e.target.closest('th[data-col-key]');
if (!th) return;
e.preventDefault();
e.stopPropagation();
const sourceKey =
table.dataset.dragColKey ||
(e.dataTransfer && e.dataTransfer.getData('text/plain')) ||
'';
const targetKey = th.getAttribute('data-col-key');
clearColumnDropMarkers(thead);
delete table.dataset.dragColKey;
if (table._dragGhost) {
table._dragGhost.remove();
table._dragGhost = null;
}
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
const order = getOrderedColumnKeys();
const from = order.indexOf(sourceKey);
const to = order.indexOf(targetKey);
if (from < 0 || to < 0) return;
order.splice(from, 1);
order.splice(to, 0, sourceKey);
saveColumnOrder(order);
refreshTableChrome(lastDevices, lastExpanded);
});
table.addEventListener('mousedown', (e) => {
const handle = e.target.closest('.col-resizer');
if (!handle) return;
e.preventDefault();
e.stopPropagation();
const colKey = handle.getAttribute('data-resize-col');
const th = handle.closest('th');
if (!colKey || !th) return;
const widths = getColWidths();
const startX = e.clientX;
const startW = th.getBoundingClientRect().width;
const onMove = (ev) => {
const w = Math.max(48, Math.round(startW + (ev.clientX - startX)));
widths[colKey] = w;
th.style.width = w + 'px';
th.style.minWidth = w + 'px';
th.style.maxWidth = w + 'px';
const cg = document.getElementById('ra-devices-colgroup');
const col = cg && cg.querySelector('col[data-col="' + colKey + '"]');
if (col) col.style.width = w + 'px';
else if (colKey === 'actions') {
const actionCol = cg && cg.querySelector('col.col-actions');
if (actionCol) actionCol.style.width = w + 'px';
} else if (colKey === 'expand') {
const expandCol = cg && cg.querySelector('col.col-expand');
if (expandCol) expandCol.style.width = w + 'px';
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveColWidths(widths);
applyColgroup();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
function statusBadge(d) {
const label = d.status_label || '';
if (label === 'needs_whitelist') {
return '<span class="tag-pill tag-pill--warn">Needs whitelist</span>';
}
if (label === 'whitelisted') {
return '<span class="tag-pill">Whitelisted</span>';
}
if (label === 'stale') {
return '<span class="muted">Stale</span>';
}
if (label === 'not_opted_in') {
return '<span class="muted">Not opted in</span>';
}
return '<span class="muted">Polling</span>';
}
function deviceTitle(d) {
const name = (d.device_name || d.device_display || '').trim();
if (name) return name;
const id = String(d.device_id || '');
if (id.length > 14) return id.slice(0, 8) + '…' + id.slice(-4);
return id;
}
function formatAbis(abis) {
if (!Array.isArray(abis) || !abis.length) return '—';
return abis.join(', ');
}
function formatCell(key, d) {
switch (key) {
case 'device_name':
return (
'<code class="ra-device-id" title="' +
esc(d.device_id) +
'">' +
esc(deviceTitle(d)) +
'</code>'
);
case 'status':
return statusBadge(d);
case 'opt_in':
return esc(d.opt_in_mode || 'none');
case 'android_api':
return d.sdk_int != null && d.sdk_int !== '' ? esc(String(d.sdk_int)) : '—';
case 'android_version':
return esc(d.os_release || '—');
case 'last_seen':
return esc(d.last_seen_at || '—');
case 'app_version':
return esc(d.app_version || '—');
default:
return '—';
}
}
function detailLines(d) {
const lines = [];
lines.push('Device ID: ' + (d.device_id || '—'));
const name = (d.device_name || d.device_display || '').trim();
if (name) lines.push('Device name: ' + name);
if (d.manufacturer || d.model) {
lines.push('Hardware: ' + [d.manufacturer, d.model].filter(Boolean).join(' '));
}
if (d.brand) lines.push('Brand: ' + d.brand);
if (d.product) lines.push('Product: ' + d.product);
if (d.hardware_device) lines.push('Device codename: ' + d.hardware_device);
if (d.sdk_int != null) lines.push('Android API: ' + d.sdk_int);
if (d.os_release) lines.push('Android version: ' + d.os_release);
if (d.abis && d.abis.length) lines.push('ABIs: ' + formatAbis(d.abis));
if (d.lan_ip) lines.push('Device LAN IP: ' + d.lan_ip);
if (d.device_wan_ip) lines.push('Device WAN IP: ' + d.device_wan_ip);
if (d.poll_source_ip) {
let pollLabel = 'Poll source IP (server view)';
if (d.poll_source_is_private) {
pollLabel += ' — intra/private hop, not device WAN';
}
lines.push(pollLabel + ': ' + d.poll_source_ip);
}
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope);
if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope);
if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key);
if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) {
const rx = formatBytes(d.wg_rx_bytes || 0);
const tx = formatBytes(d.wg_tx_bytes || 0);
lines.push('VPN traffic (BE wg): ↓' + rx + ' ↑' + tx);
}
if (d.wg_latest_handshake > 0) {
lines.push('VPN last handshake: ' + new Date(d.wg_latest_handshake * 1000).toISOString());
}
if (d.wg_endpoint) lines.push('VPN endpoint: ' + d.wg_endpoint);
lines.push('App version: ' + (d.app_version || '—'));
lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none'));
lines.push('Last seen: ' + (d.last_seen_at || '—'));
if (d.notes) lines.push('Notes: ' + d.notes);
if (d.issue_count != null) lines.push('Linked issues: ' + d.issue_count);
if (d.graph_session_count != null) lines.push('Graph sessions: ' + d.graph_session_count);
return lines;
}
function detailLinks(d) {
const links = d.links || [];
if (!links.length) return '';
let html = '<div class="ra-detail-links">';
links.forEach((link) => {
html +=
'<a class="btn btn-sm" href="' +
esc(link.href) +
'">' +
esc(link.label) +
'</a> ';
});
html += '</div>';
return html;
}
function isInteractiveTarget(el) {
return !!el.closest('.ra-device-actions, .ra-detail-links, .report-tree-toggle, button, a, input, select, textarea, label');
}
function bindRaTree(root) {
root.querySelectorAll('.report-row[data-device-id]').forEach((row) => {
const btn = row.querySelector('.report-tree-toggle');
const briefId = btn ? btn.getAttribute('aria-controls') : null;
const briefRow = briefId ? document.getElementById(briefId) : null;
if (!briefRow || !btn) return;
const toggle = (e) => {
if (e && isInteractiveTarget(e.target)) return;
const open = briefRow.hidden;
briefRow.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
row.classList.toggle('report-row--open', open);
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggle(e);
});
row.addEventListener('click', (e) => toggle(e));
row.addEventListener('keydown', (e) => {
if (isInteractiveTarget(e.target)) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle(e);
}
});
});
}
function captureExpandedDeviceIds() {
const ids = new Set();
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return ids;
tbody.querySelectorAll('.report-row.report-row--open[data-device-id]').forEach((row) => {
const id = row.getAttribute('data-device-id');
if (id) ids.add(id);
});
return ids;
}
let lastDevices = [];
let lastExpanded = new Set();
function renderDevices(devices, expandedIds) {
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return;
lastDevices = devices || [];
if (expandedIds) lastExpanded = expandedIds;
const cols = getDisplayColumns();
const span = colspan();
if (!devices || !devices.length) {
tbody.innerHTML =
'<tr><td colspan="' +
span +
'" class="muted">No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll.</td></tr>';
return;
}
let html = '';
devices.forEach((d, i) => {
const rowKey = 'ra-' + i;
const briefId = 'ra-brief-' + rowKey;
const wl = Number(d.whitelisted) === 1;
const optIn = d.opt_in_mode || 'none';
const openReady = wl && (optIn === 'wireguard' || optIn === 'rssh');
const openHint = !canOperate()
? 'Need remote_access_operate permission'
: !wl
? 'Whitelist device first'
: openReady
? 'Open session — device connects on next poll (≤7 min)'
: 'Phone must poll with RSSH/WG enabled (dev settings on device; wait ≤7 min)';
const openDisabled = canOperate() ? '' : ' disabled';
const wlDisabled = canAdmin() ? '' : ' disabled';
const isOpen = lastExpanded.has(String(d.device_id || ''));
const rowClass =
'report-row' +
(d.needs_whitelist ? ' ra-device-row--needs-wl' : '') +
(isOpen ? ' report-row--open' : '');
html += '<tr class="' + rowClass + '" data-device-id="' + esc(d.device_id) + '" tabindex="0">';
html +=
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="' +
(isOpen ? 'true' : 'false') +
'" aria-controls="' +
briefId +
'" title="Show device details"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
cols.forEach((c) => {
html += '<td data-col="' + esc(c.key) + '">' + formatCell(c.key, d) + '</td>';
});
html +=
'<td class="ra-device-actions col-actions">' +
'<button type="button" class="btn btn-sm btn-primary"' +
openDisabled +
' data-open-session="' +
esc(d.device_id) +
'" title="' +
esc(openHint) +
'">Open session</button> ' +
'<button type="button" class="btn btn-sm"' +
wlDisabled +
' data-toggle-wl="' +
esc(d.device_id) +
'" data-wl="' +
(wl ? '0' : '1') +
'">' +
(wl ? 'Revoke WL' : 'Whitelist') +
'</button> ' +
'<button type="button" class="btn btn-sm" data-copy-id="' +
esc(d.device_id) +
'">Copy ID</button>' +
'</td>';
html += '</tr>';
html +=
'<tr class="report-brief-row" id="' +
briefId +
'"' +
(isOpen ? '' : ' hidden') +
'><td colspan="' +
span +
'" class="report-brief-cell"><div class="report-brief-panel"><div class="report-brief">';
detailLines(d).forEach((line) => {
html += '<p>' + esc(line) + '</p>';
});
html += detailLinks(d);
html += '</div></div></td></tr>';
});
tbody.innerHTML = html;
bindRaTree(tbody);
}
function refreshTableChrome(devices, expandedIds) {
applyColgroup();
renderHead();
renderDevices(devices || lastDevices, expandedIds || lastExpanded);
}
function renderSessions(active, inactive) {
const aBody = document.getElementById('ra-active-tbody');
const iBody = document.getElementById('ra-inactive-tbody');
if (aBody) {
aBody.innerHTML = '';
if (!active || !active.length) {
aBody.innerHTML = '<tr><td colspan="6" class="muted">No pending or active sessions.</td></tr>';
}
(active || []).forEach((s) => {
const tr = document.createElement('tr');
const closeBtn = canOperate()
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
: '';
let endpoint = s.endpoint || '—';
if (s.tunnel === 'ssh_reverse' && s.rssh_remote_bind) {
endpoint = endpoint + ' → ' + esc(s.rssh_remote_bind);
}
let operator = '';
if (s.rssh_operator) {
operator =
'<div class="ra-rssh-cmds muted"><code>' +
esc(s.rssh_operator.shell || '') +
'</code></div>';
}
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.tunnel) + '</td>' +
'<td>' + endpoint + operator + '</td>' +
'<td>' + closeBtn + '</td>';
aBody.appendChild(tr);
});
}
if (iBody) {
iBody.innerHTML = '';
(inactive || []).forEach((s) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.closed_at || '—') + '</td>' +
'<td>' + esc(s.close_reason || '—') + '</td>';
iBody.appendChild(tr);
});
if (!inactive || !inactive.length) {
iBody.innerHTML = '<tr><td colspan="5" class="muted">No closed sessions yet.</td></tr>';
}
}
}
function renderEvents(events) {
const tbody = document.getElementById('ra-events-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!events || !events.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No audit events.</td></tr>';
return;
}
(events || []).forEach((e) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(e.created_at) + '</td>' +
'<td><code>' + esc(e.device_id) + '</code></td>' +
'<td>' + esc(e.action) + '</td>' +
'<td>' + esc(e.reason || '—') + '</td>';
tbody.appendChild(tr);
});
}
let lastConfig = {};
async function refresh() {
const expanded = captureExpandedDeviceIds();
setStatus('Loading…');
try {
const data = await fetchJson(apiUrl('dashboard'));
lastConfig = data.config || {};
refreshTableChrome(data.devices, expanded);
renderSessions(data.active_sessions, data.inactive_sessions);
renderEvents(data.recent_events);
const ep = lastConfig.wg_endpoint ? ' · WG ' + lastConfig.wg_endpoint : '';
setStatus('Updated ' + new Date().toLocaleTimeString() + ep);
} catch (e) {
setStatus('Failed: ' + e.message, true);
}
}
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
if (t.closest('.report-tree-toggle, .ra-detail-links a')) return;
const copyId = t.getAttribute('data-copy-id');
if (copyId) {
try {
await navigator.clipboard.writeText(copyId);
setStatus('Copied device ID');
} catch (e) {
setStatus('Copy failed', true);
}
return;
}
const openId = t.getAttribute('data-open-session');
if (openId) {
if (!canOperate()) return;
const dev = (lastDevices || []).find((d) => String(d.device_id) === openId);
const wl = dev && Number(dev.whitelisted) === 1;
const optIn = dev ? dev.opt_in_mode || 'none' : 'none';
if (!wl) {
setStatus('Whitelist device ' + openId + ' first', true);
return;
}
if (optIn !== 'wireguard' && optIn !== 'rssh') {
setStatus(
'Device opt-in is "' +
optIn +
'". On phone: dev settings → Remote access → RSSH, then wait for poll (≤7 min).',
true
);
return;
}
try {
setStatus('Opening session for ' + openId + '…');
await fetchJson(apiUrl('open_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: openId }),
});
setStatus('Session opened — device will connect on next poll (≤7 min)');
await refresh();
} catch (e) {
setStatus('Open session: ' + e.message, true);
}
return;
}
const closeId = t.getAttribute('data-close-session');
if (closeId) {
if (!canOperate()) return;
try {
await fetchJson(apiUrl('close_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: closeId,
device_id: t.getAttribute('data-device') || '',
}),
});
await refresh();
} catch (e) {
setStatus('Close session: ' + e.message, true);
}
return;
}
const wlDevice = t.getAttribute('data-toggle-wl');
if (wlDevice) {
if (!canAdmin()) return;
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: wlDevice,
whitelisted: t.getAttribute('data-wl') === '1',
}),
});
await refresh();
} catch (e) {
setStatus('Whitelist: ' + e.message, true);
}
}
});
const form = document.getElementById('ra-whitelist-form');
if (form) {
if (!canAdmin()) form.hidden = true;
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!canAdmin()) return;
const fd = new FormData(form);
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: fd.get('device_id'),
notes: fd.get('notes'),
whitelisted: true,
}),
});
form.reset();
await refresh();
} catch (e) {
setStatus('Whitelist add: ' + e.message, true);
}
});
}
bindTableLayout();
applyColgroup();
renderHead();
refresh();
setInterval(refresh, 30000);
})();

View File

@@ -0,0 +1,448 @@
(function () {
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function canOperate() {
return document.body.getAttribute('data-can-sl-operate') === '1';
}
function canAdmin() {
return document.body.getAttribute('data-can-sl-admin') === '1';
}
function apiUrl(action, params) {
const q = new URLSearchParams(params || {});
q.set('action', action);
return basePath() + '/api/short_links.php?' + q.toString();
}
function setStatus(msg, isError) {
const el = document.getElementById('sl-status');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isError);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
const err = data.error || data.reason || ('HTTP ' + res.status);
throw new Error(err);
}
return data;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function truncate(s, n) {
const t = String(s || '');
return t.length <= n ? t : t.slice(0, n - 1) + '…';
}
let selectedBearerId = 0;
let selectedBearerLabel = '';
function bearerCaps(b) {
const parts = [];
if (b.can_temporary) parts.push('temp');
if (b.can_permanent) parts.push('perm');
return parts.length ? parts.join(', ') : '—';
}
function renderBearers(bearers) {
const tbody = document.getElementById('sl-bearers-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!bearers || !bearers.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">No bearer tokens yet — mint one below.</td></tr>';
return;
}
(bearers || []).forEach((b) => {
const tr = document.createElement('tr');
const active = b.active !== false && !b.revoked_at;
const status = active ? '<span class="tag-pill">active</span>' : 'revoked';
const revokeBtn = canOperate() && active
? ' <button type="button" class="btn btn-sm" data-revoke-bearer="' + esc(String(b.id)) + '">Revoke</button>'
: '';
const viewBtn =
'<button type="button" class="btn btn-sm btn-primary" data-view-links="' + esc(String(b.id)) + '" data-label="' + esc(b.label || '') + '">Links</button>';
tr.innerHTML =
'<td>' + esc(b.id) + '</td>' +
'<td>' + esc(b.label) + '</td>' +
'<td>' + status + '</td>' +
'<td><code>' + esc(bearerCaps(b)) + '</code></td>' +
'<td>' + esc(b.link_count) + (b.expired_link_count > 0 ? ' <span class="muted">(' + esc(b.expired_link_count) + ' exp)</span>' : '') + '</td>' +
'<td>' + esc(b.rate_limit_per_hour) + '</td>' +
'<td>' + esc(b.created_at || '—') + '</td>' +
'<td>' + viewBtn + revokeBtn + '</td>';
tbody.appendChild(tr);
});
}
function renderSigners(signers) {
const tbody = document.getElementById('sl-signers-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!signers || !signers.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No signer keys — mint one for permanent links.</td></tr>';
return;
}
(signers || []).forEach((s) => {
const tr = document.createElement('tr');
const active = s.active !== false && !s.revoked_at;
const status = active ? '<span class="tag-pill">active</span>' : 'revoked';
const revokeBtn = canOperate() && active
? ' <button type="button" class="btn btn-sm" data-revoke-signer="' + esc(String(s.id)) + '">Revoke</button>'
: '';
tr.innerHTML =
'<td>' + esc(s.id) + '</td>' +
'<td>' + esc(s.label) + '</td>' +
'<td>' + status + '</td>' +
'<td>' + esc(s.created_at || '—') + '</td>' +
'<td>' + revokeBtn + '</td>';
tbody.appendChild(tr);
});
}
function renderLinks(links) {
const tbody = document.getElementById('sl-links-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!links || !links.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No links for this bearer.</td></tr>';
return;
}
(links || []).forEach((l) => {
const tr = document.createElement('tr');
const exp = l.expired
? '<span class="muted">expired</span>'
: (l.ttl_seconds === 0 || !l.expires_at ? '<span class="tag-pill">permanent</span>' : esc(l.expires_at));
tr.innerHTML =
'<td><a href="' + esc(l.short_url) + '" target="_blank" rel="noopener">' + esc(l.short_url) + '</a> ' +
'<button type="button" class="btn btn-sm" data-copy="' + esc(l.short_url) + '">Copy</button></td>' +
'<td title="' + esc(l.origin) + '">' + esc(truncate(l.origin, 64)) + '</td>' +
'<td>' + exp + '</td>' +
'<td>' + esc(l.access_count) + '</td>' +
'<td><a class="btn btn-sm" href="' + esc(l.qr_url) + '" target="_blank" rel="noopener">PNG</a></td>';
tbody.appendChild(tr);
});
}
function renderAudit(rows) {
const tbody = document.getElementById('sl-audit-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!rows || !rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No audit events.</td></tr>';
return;
}
(rows || []).forEach((e) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(e.created_at) + '</td>' +
'<td>' + esc(e.event) + '</td>' +
'<td>' + esc(e.bearer_id != null ? e.bearer_id : '—') + '</td>' +
'<td>' + esc(e.slug || '—') + '</td>' +
'<td>' + esc(e.detail || '—') + '</td>';
tbody.appendChild(tr);
});
}
function showSecretModal(modalId, valueId, value) {
const modal = document.getElementById(modalId);
const val = document.getElementById(valueId);
if (!modal || !val) return;
val.textContent = value;
modal.hidden = false;
}
function hideModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) modal.hidden = true;
}
function syncPermanentUi() {
const permCb = document.getElementById('sl-permanent-cb');
const ttlWrap = document.getElementById('sl-ttl-wrap');
const signerWrap = document.getElementById('sl-signer-wrap');
const ttlInput = document.getElementById('sl-ttl-input');
const permanent = permCb && permCb.checked;
if (ttlWrap) ttlWrap.hidden = !!permanent;
if (signerWrap) signerWrap.hidden = !permanent;
if (ttlInput && permanent) ttlInput.value = '0';
}
function syncMintPermanentUi() {
const permCb = document.getElementById('sl-can-permanent');
const wrap = document.getElementById('sl-mint-signer-wrap');
if (wrap) wrap.hidden = !(permCb && permCb.checked);
}
async function loadLinks(bearerId, label) {
selectedBearerId = bearerId;
selectedBearerLabel = label || String(bearerId);
const section = document.getElementById('sl-links-section');
const labelEl = document.getElementById('sl-links-bearer-label');
const hidden = document.getElementById('sl-shorten-bearer-id');
if (section) section.hidden = false;
if (labelEl) labelEl.textContent = selectedBearerLabel + ' (#' + bearerId + ')';
if (hidden) hidden.value = String(bearerId);
setStatus('Loading links…');
const data = await fetchJson(apiUrl('links', { bearer_id: String(bearerId) }));
renderLinks(data.links);
setStatus('Links for bearer #' + bearerId);
}
async function refresh() {
setStatus('Loading…');
try {
const data = await fetchJson(apiUrl('dashboard'));
renderBearers(data.bearers);
renderSigners(data.signers);
renderAudit(data.audit);
if (selectedBearerId > 0) {
await loadLinks(selectedBearerId, selectedBearerLabel);
}
setStatus('Updated ' + new Date().toLocaleTimeString() + ' · ' + (data.public_base || ''));
} catch (e) {
setStatus('Failed: ' + e.message, true);
}
}
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
if (t.id === 'sl-token-close' || t.closest('#sl-token-close')) {
hideModal('sl-token-modal');
return;
}
if (t.id === 'sl-signer-close' || t.closest('#sl-signer-close')) {
hideModal('sl-signer-modal');
return;
}
if (t.id === 'sl-token-copy') {
const val = document.getElementById('sl-token-value');
if (val) {
try {
await navigator.clipboard.writeText(val.textContent || '');
setStatus('Token copied');
} catch (e) {
setStatus('Copy failed', true);
}
}
return;
}
if (t.id === 'sl-signer-copy') {
const val = document.getElementById('sl-signer-value');
if (val) {
try {
await navigator.clipboard.writeText(val.textContent || '');
setStatus('Signer copied');
} catch (e) {
setStatus('Copy failed', true);
}
}
return;
}
const copy = t.getAttribute('data-copy');
if (copy) {
try {
await navigator.clipboard.writeText(copy);
setStatus('Copied short URL');
} catch (e) {
setStatus('Copy failed', true);
}
return;
}
const viewId = t.getAttribute('data-view-links');
if (viewId) {
try {
await loadLinks(parseInt(viewId, 10), t.getAttribute('data-label') || '');
} catch (e) {
setStatus('Links: ' + e.message, true);
}
return;
}
const revokeId = t.getAttribute('data-revoke-bearer');
if (revokeId) {
if (!canOperate()) return;
if (!window.confirm('Revoke bearer #' + revokeId + '?')) return;
try {
await fetchJson(apiUrl('revoke_bearer'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bearer_id: parseInt(revokeId, 10) }),
});
if (selectedBearerId === parseInt(revokeId, 10)) {
selectedBearerId = 0;
const section = document.getElementById('sl-links-section');
if (section) section.hidden = true;
}
await refresh();
} catch (e) {
setStatus('Revoke: ' + e.message, true);
}
return;
}
const revokeSignerId = t.getAttribute('data-revoke-signer');
if (revokeSignerId) {
if (!canOperate()) return;
if (!window.confirm('Revoke signer #' + revokeSignerId + '?')) return;
try {
await fetchJson(apiUrl('revoke_signer'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signer_id: parseInt(revokeSignerId, 10) }),
});
await refresh();
} catch (e) {
setStatus('Revoke signer: ' + e.message, true);
}
}
});
const permCb = document.getElementById('sl-permanent-cb');
if (permCb) {
permCb.addEventListener('change', syncPermanentUi);
syncPermanentUi();
}
const mintPermCb = document.getElementById('sl-can-permanent');
if (mintPermCb) {
mintPermCb.addEventListener('change', syncMintPermanentUi);
syncMintPermanentUi();
}
const mintForm = document.getElementById('sl-mint-form');
if (mintForm) {
mintForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!canOperate()) return;
const fd = new FormData(mintForm);
try {
setStatus('Minting bearer…');
const data = await fetchJson(apiUrl('mint_bearer'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: fd.get('label'),
rate_limit_per_hour: parseInt(String(fd.get('rate_limit') || '1000'), 10),
can_temporary: fd.get('can_temporary') === '1',
can_permanent: fd.get('can_permanent') === '1',
mint_signer: fd.get('mint_signer') === '1',
}),
});
mintForm.reset();
syncMintPermanentUi();
showSecretModal('sl-token-modal', 'sl-token-value', data.token);
if (data.signer_key) {
showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key);
}
await refresh();
} catch (e) {
setStatus('Mint: ' + e.message, true);
}
});
}
const mintSignerForm = document.getElementById('sl-mint-signer-form');
if (mintSignerForm) {
mintSignerForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!canOperate()) return;
const fd = new FormData(mintSignerForm);
try {
const data = await fetchJson(apiUrl('mint_signer'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: fd.get('label') }),
});
mintSignerForm.reset();
showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key);
await refresh();
} catch (e) {
setStatus('Mint signer: ' + e.message, true);
}
});
}
const shortenForm = document.getElementById('sl-shorten-form');
if (shortenForm) {
shortenForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!canOperate()) return;
const fd = new FormData(shortenForm);
const permanent = fd.get('permanent') === '1';
try {
const payload = {
bearer_id: parseInt(String(fd.get('bearer_id') || '0'), 10),
url: fd.get('url'),
ttl: permanent ? 0 : parseInt(String(fd.get('ttl') || '86400'), 10),
};
if (permanent) {
payload.signer = fd.get('signer');
}
const data = await fetchJson(apiUrl('create_link'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (data.link && data.link.short_url) {
setStatus('Short URL: ' + data.link.short_url);
}
shortenForm.querySelector('[name="url"]').value = '';
await refresh();
} catch (e) {
setStatus('Shorten: ' + e.message, true);
}
});
}
const purgeBtn = document.getElementById('sl-purge-btn');
if (purgeBtn) {
if (!canAdmin()) purgeBtn.hidden = true;
purgeBtn.addEventListener('click', async () => {
if (!canAdmin()) return;
if (!window.confirm('Delete all expired links and audit rows older than 90 days?')) return;
try {
const data = await fetchJson(apiUrl('purge_expired'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
setStatus('Purged links=' + (data.purged && data.purged.links) + ' audit=' + (data.purged && data.purged.audit));
await refresh();
} catch (e) {
setStatus('Purge: ' + e.message, true);
}
});
}
['sl-token-modal', 'sl-signer-modal'].forEach((id) => {
const modal = document.getElementById(id);
if (modal) {
modal.addEventListener('click', (ev) => {
if (ev.target === modal) hideModal(id);
});
}
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
hideModal('sl-token-modal');
hideModal('sl-signer-modal');
}
});
refresh();
})();

View File

@@ -0,0 +1,132 @@
/**
* Create ticket/issue dialog — Issues toolbar uses "issue" type tag; Tickets uses "ticket".
*/
(function () {
const META = {
ticket: { id: 'ticket', label: 'ticket', bg: '#6366f1' },
issue: { id: 'issue', label: 'issue', bg: '#0d9488' },
};
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function t(key) {
if (window.CrashI18n && window.CrashI18n.t) {
return window.CrashI18n.t(key);
}
return key;
}
function openDialog(kind) {
const dialog = document.getElementById('ticket-create-dialog');
const form = document.getElementById('ticket-create-form');
const status = document.getElementById('ticket-create-status');
const heading = document.getElementById('ticket-create-heading');
if (!dialog || !form) return;
const createKind = kind === 'issue' ? 'issue' : 'ticket';
form.dataset.createKind = createKind;
if (heading) {
heading.textContent = t(createKind === 'issue' ? 'issues.new' : 'tickets.new');
}
if (status) status.textContent = '';
form.reset();
if (typeof dialog.showModal === 'function') {
dialog.showModal();
}
}
function init() {
const dialog = document.getElementById('ticket-create-dialog');
const form = document.getElementById('ticket-create-form');
const cancelBtn = document.getElementById('ticket-create-cancel');
const closeBtn = document.getElementById('ticket-create-close');
const status = document.getElementById('ticket-create-status');
if (!dialog || !form) return;
document.querySelectorAll('.js-new-issue-btn').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
openDialog('issue');
});
});
document.querySelectorAll('.js-new-ticket-btn').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
openDialog('ticket');
});
});
function closeDialog() {
dialog.close();
}
if (cancelBtn) cancelBtn.addEventListener('click', closeDialog);
if (closeBtn) closeBtn.addEventListener('click', closeDialog);
dialog.addEventListener('click', (ev) => {
if (ev.target === dialog) closeDialog();
});
form.addEventListener('submit', (ev) => {
ev.preventDefault();
const titleEl = document.getElementById('ticket-create-title');
const briefEl = document.getElementById('ticket-create-brief');
const bodyEl = document.getElementById('ticket-create-body');
const kind = form.dataset.createKind === 'issue' ? 'issue' : 'ticket';
const meta = META[kind] || META.ticket;
const payload = {
title: titleEl && titleEl.value ? titleEl.value.trim() : '',
brief: briefEl && briefEl.value ? briefEl.value.trim() : '',
body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '',
tags: [
meta,
{ id: 'open', label: 'open', bg: '#22c55e' },
],
};
if (!payload.title) return;
if (status) status.textContent = 'Creating…';
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/ticket_create.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data = null;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (status) status.textContent = 'Invalid response';
return;
}
if (!data || !data.ok) {
if (status) status.textContent = (data && data.error) || 'Create failed';
return;
}
dialog.close();
const view = document.body.getAttribute('data-view') || '';
if (view === 'tickets' && typeof window.__ticketsReload === 'function') {
window.__ticketsReload();
} else if (data.id) {
window.location.href = basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.id));
} else {
window.location.href = basePath() + '/?view=tickets';
}
};
xhr.onerror = function () {
if (status) status.textContent = 'Network error';
};
xhr.send(JSON.stringify(payload));
});
}
function onReady(fn) {
if (window.CrashI18n && window.CrashI18n.ready) {
window.CrashI18n.ready.then(fn);
} else {
fn();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => onReady(init));
} else {
onReady(init);
}
})();

View File

@@ -0,0 +1,373 @@
(function () {
function t(key, params) {
return window.CrashI18n ? window.CrashI18n.t(key, params) : key;
}
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatTime(ms) {
if (!ms) return '—';
const d = new Date(Number(ms));
return d.toISOString().replace('T', ' ').slice(0, 19);
}
function formatStars(n) {
const score = Math.max(0, Math.min(5, Number(n) || 0));
let html =
'<span class="star-rating" role="img" aria-label="' + score + ' out of 5">';
for (let i = 1; i <= 5; i++) {
html += '<span class="star' + (i <= score ? ' star--on' : '') + '"></span>';
}
return html + '</span>';
}
function renderTagPill(tag) {
const id = String(tag.id || '').trim();
const label = escapeHtml(tag.label || tag.id);
const bg = escapeHtml(tag.bg || '#5c6b82');
if (!id) {
return '<span class="report-tag" style="--tag-bg:' + bg + '">' + label + '</span>';
}
return (
'<button type="button" class="report-tag report-tag--filter" data-tag-id="' +
escapeHtml(id) +
'" style="--tag-bg:' +
bg +
'" title="Filter by tag">' +
label +
'</button>'
);
}
function canTagEdit() {
return document.body.getAttribute('data-can-tag-edit') === '1';
}
function initTicketsApp() {
const app = document.getElementById('tickets-app');
const tbody = document.getElementById('tickets-tbody');
const statusEl = document.getElementById('tickets-status');
const pagination = document.getElementById('tickets-pagination');
const perPageSel = document.getElementById('tickets-per-page');
const tagFilter = document.getElementById('tickets-tag-filter');
const thead = document.querySelector('#tickets-table thead');
if (!app || !tbody) return;
let page = 1;
let perPage = 50;
let sort = 'opened_at_ms';
let dir = 'desc';
let tag = '';
function load() {
statusEl.textContent = t('reports.loading');
const q =
'?page=' +
page +
'&per_page=' +
perPage +
'&sort=' +
encodeURIComponent(sort) +
'&dir=' +
encodeURIComponent(dir) +
(tag ? '&tag=' + encodeURIComponent(tag) : '');
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/tickets.php' + q, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
statusEl.textContent = t('reports.invalid_response');
return;
}
if (!data.ok) {
statusEl.textContent = data.hint || data.error || t('reports.load_failed');
return;
}
renderRows(data.items || []);
renderPagination(data.total || 0, data.page || 1);
const from = data.total ? (data.page - 1) * data.per_page + 1 : 0;
const to = Math.min(data.page * data.per_page, data.total);
statusEl.textContent = t('reports.status_showing', {
from,
to,
total: data.total,
});
};
xhr.onerror = function () {
statusEl.textContent = t('reports.network_error');
};
xhr.send();
}
window.ticketsReloadRef = load;
function renderRows(items) {
if (!items.length) {
tbody.innerHTML =
'<tr><td colspan="6" class="muted">' + escapeHtml(t('tickets.empty')) + '</td></tr>';
return;
}
let html = '';
items.forEach((row, i) => {
const briefId = 'tkt-brief-' + row.id + '-' + i;
const openUrl = basePath() + '/?view=ticket&id=' + row.id;
const issueTitle = escapeHtml(row.title || '');
const issueBrief = row.brief
? '<div class="ticket-issue-brief muted">' + escapeHtml(row.brief) + '</div>'
: '';
html +=
'<tr class="report-row report-row--nav" data-href="' +
escapeHtml(openUrl) +
'" tabindex="0" role="link">';
html +=
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
briefId +
'"><span class="report-tree-arrow"></span></button></td>';
html +=
'<td class="ticket-col-issue"><strong class="ticket-issue-title">' +
issueTitle +
'</strong>' +
issueBrief +
'</td>';
html += '<td>' + formatTime(row.opened_at_ms) + '</td>';
html +=
'<td class="ticket-col-env"><span>' +
escapeHtml(row.env_brief || '') +
'</span><br><span class="muted">' +
escapeHtml(row.device_model || '') +
'</span></td>';
html += '<td>' + formatStars(row.rating) + '</td>';
const tagEditBtn = canTagEdit()
? '<button type="button" class="tag-edit-btn" data-ticket-id="' +
row.id +
'" title="Edit tags">✎</button>'
: '';
const tags = (row.tags || []).map(renderTagPill).join('');
html +=
'<td class="col-tags"><div class="report-tags">' +
tags +
tagEditBtn +
'</div></td>';
html += '</tr>';
html +=
'<tr class="report-brief-row" id="' +
briefId +
'" hidden><td colspan="6"><div class="report-brief-panel report-row--nav" data-href="' +
escapeHtml(openUrl) +
'" tabindex="0" role="link"><p class="muted">' +
escapeHtml(t('row.open_report')) +
'</p><ul class="report-brief-lines">';
[row.brief, row.env_brief, 'Owner: ' + (row.owner_username || '—')]
.filter(Boolean)
.forEach((line) => {
html += '<li>' + escapeHtml(String(line)) + '</li>';
});
html += '</ul></div></td></tr>';
});
tbody.innerHTML = html;
bindRows();
}
function applyTagFilter(tagId) {
tag = tagId;
if (tagFilter) {
let found = false;
tagFilter.querySelectorAll('option').forEach((opt) => {
if (opt.value === tagId) found = true;
});
if (!found) {
const opt = document.createElement('option');
opt.value = tagId;
opt.textContent = tagId;
tagFilter.appendChild(opt);
}
tagFilter.value = tagId;
}
page = 1;
load();
}
function activateTag(tagId, ev) {
if (!tagId) return;
ev.stopPropagation();
ev.preventDefault();
const xhr = new XMLHttpRequest();
xhr.open(
'GET',
basePath() +
'/api/tickets.php?page=1&per_page=2&tag=' +
encodeURIComponent(tagId),
true
);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok) return;
const total = Number(data.total || 0);
if (total === 1 && data.items && data.items[0] && data.items[0].id) {
window.location.href =
basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.items[0].id));
return;
}
applyTagFilter(tagId);
};
xhr.send();
}
function bindRows() {
tbody.querySelectorAll('.report-tree-toggle').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
const controls = btn.getAttribute('aria-controls');
const row = controls ? document.getElementById(controls) : null;
if (!row) return;
const open = row.hidden;
row.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
});
tbody.querySelectorAll('[data-href]').forEach((el) => {
const href = el.getAttribute('data-href');
if (!href) return;
const go = () => {
window.location.href = href;
};
el.addEventListener('click', (e) => {
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
go();
});
el.addEventListener('keydown', (e) => {
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
go();
}
});
});
tbody.querySelectorAll('.tag-edit-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (typeof window.openTagModal === 'function') {
window.openTagModal(Number(btn.getAttribute('data-ticket-id')), 'ticket');
}
});
});
tbody.querySelectorAll('.report-tag--filter').forEach((btn) => {
btn.addEventListener('click', (e) => {
activateTag(btn.getAttribute('data-tag-id') || '', e);
});
});
}
function renderPagination(total, currentPage) {
if (!pagination) return;
page = currentPage;
const pages = Math.max(1, Math.ceil(total / perPage));
const prev = page > 1 ? page - 1 : null;
const next = page < pages ? page + 1 : null;
let html = '<div class="pagination-inner">';
html +=
'<button type="button" class="btn page-btn" data-page="' +
(prev || '') +
'" ' +
(prev ? '' : 'disabled') +
'>← Previous</button>';
html +=
'<span class="page-info">Page ' +
page +
' / ' +
pages +
' · ' +
total +
' tickets</span>';
html +=
'<button type="button" class="btn page-btn" data-page="' +
(next || '') +
'" ' +
(next ? '' : 'disabled') +
'>Next →</button></div>';
pagination.innerHTML = html;
pagination.querySelectorAll('.page-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const p = Number(btn.getAttribute('data-page'));
if (!p) return;
page = p;
load();
});
});
}
if (perPageSel) {
perPageSel.addEventListener('change', () => {
perPage = Number(perPageSel.value) || 50;
page = 1;
load();
});
}
if (tagFilter) {
tagFilter.addEventListener('change', () => {
tag = tagFilter.value || '';
page = 1;
load();
});
}
if (thead) {
thead.querySelectorAll('.sortable').forEach((th) => {
th.addEventListener('click', () => {
const key = th.getAttribute('data-sort');
if (!key) return;
if (sort === key) {
dir = dir === 'asc' ? 'desc' : 'asc';
} else {
sort = key;
dir = 'desc';
}
thead.querySelectorAll('.sortable').forEach((x) => {
x.classList.toggle('sortable--active', x === th);
});
page = 1;
load();
});
});
}
window.__ticketsReload = function () {
page = 1;
load();
};
load();
}
function onReady(fn) {
if (window.CrashI18n && window.CrashI18n.ready) {
window.CrashI18n.ready.then(fn);
} else {
fn();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => onReady(initTicketsApp));
} else {
onReady(initTicketsApp);
}
})();

View File

@@ -0,0 +1,66 @@
(function () {
'use strict';
function boot() {
var form = document.querySelector('form.login-card');
var input = document.getElementById('twofa-code-input');
if (!form || !input) {
return;
}
input.focus();
input.select();
function digitsOnly(val) {
return String(val || '').replace(/\D/g, '').slice(0, 6);
}
function maybeSubmit() {
if (input.value.length === 6) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
}
input.addEventListener('input', function () {
var next = digitsOnly(input.value);
if (next !== input.value) {
input.value = next;
}
maybeSubmit();
});
input.addEventListener('paste', function (ev) {
ev.preventDefault();
var text = (ev.clipboardData && ev.clipboardData.getData('text')) || '';
input.value = digitsOnly(text);
maybeSubmit();
});
document.addEventListener('keydown', function (ev) {
if (!/^[0-9]$/.test(ev.key)) {
return;
}
var ae = document.activeElement;
if (ae === input) {
return;
}
var tag = ae && ae.tagName ? ae.tagName.toLowerCase() : '';
if (tag === 'input' || tag === 'textarea' || tag === 'select' || (ae && ae.isContentEditable)) {
return;
}
ev.preventDefault();
input.focus();
input.value = digitsOnly(input.value + ev.key);
maybeSubmit();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

View File

@@ -0,0 +1,404 @@
<?php
/*
* package examples/crash_reporter/backend/public/index.php
* index.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, 63 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 90d9b287d10572f80696355281ba308437d49936dba1d421037afbabc1dc135e
*/
declare(strict_types=1);
require_once __DIR__ . '/../src/bootstrap.php';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$base = Auth::basePath();
$route = resolve_console_route($uri);
if ($route === '/api/upload.php' || str_ends_with($route, '/api/upload.php')) {
require __DIR__ . '/api/upload.php';
exit;
}
if ($route === '/api/diag.php' || str_ends_with($route, '/api/diag.php')) {
require __DIR__ . '/api/diag.php';
exit;
}
if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) {
require __DIR__ . '/api/heartbeat.php';
exit;
}
if ($route === '/api/reports.php' || str_ends_with($route, '/api/reports.php')) {
require __DIR__ . '/api/reports.php';
exit;
}
if ($route === '/api/report_viewed.php' || str_ends_with($route, '/api/report_viewed.php')) {
require __DIR__ . '/api/report_viewed.php';
exit;
}
if ($route === '/api/report_tags.php' || str_ends_with($route, '/api/report_tags.php')) {
require __DIR__ . '/api/report_tags.php';
exit;
}
if ($route === '/api/ticket_upload.php' || str_ends_with($route, '/api/ticket_upload.php')) {
require __DIR__ . '/api/ticket_upload.php';
exit;
}
if ($route === '/api/ticket_create.php' || str_ends_with($route, '/api/ticket_create.php')) {
require __DIR__ . '/api/ticket_create.php';
exit;
}
if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) {
require __DIR__ . '/api/tickets.php';
exit;
}
if ($route === '/api/ticket_tags.php' || str_ends_with($route, '/api/ticket_tags.php')) {
require __DIR__ . '/api/ticket_tags.php';
exit;
}
if ($route === '/api/ticket_update.php' || str_ends_with($route, '/api/ticket_update.php')) {
require __DIR__ . '/api/ticket_update.php';
exit;
}
if ($route === '/api/ticket_comments.php' || str_ends_with($route, '/api/ticket_comments.php')) {
require __DIR__ . '/api/ticket_comments.php';
exit;
}
if ($route === '/api/users.php' || str_ends_with($route, '/api/users.php')) {
require __DIR__ . '/api/users.php';
exit;
}
if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/ticket_attachments.php')) {
require __DIR__ . '/api/ticket_attachments.php';
exit;
}
if ($route === '/api/graphs.php' || str_ends_with($route, '/api/graphs.php') || str_ends_with($route, '/graphs/api/graphs.php')) {
require __DIR__ . '/api/graphs.php';
exit;
}
if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_upload.php') || str_ends_with($route, '/graphs/api/graph_upload.php')) {
require __DIR__ . '/api/graph_upload.php';
exit;
}
if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_access.php')) {
require __DIR__ . '/api/remote_access.php';
exit;
}
if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php')) {
require __DIR__ . '/api/live_cast.php';
exit;
}
if ($route === '/api/live_cast_signaling.php' || str_ends_with($route, '/api/live_cast_signaling.php')) {
require __DIR__ . '/api/live_cast_signaling.php';
exit;
}
if ($route === '/live/join' || str_ends_with($route, '/live/join')) {
require __DIR__ . '/../views/live_join.php';
exit;
}
if ($route === '/live/education' || str_ends_with($route, '/live/education')) {
require __DIR__ . '/../views/live_education.php';
exit;
}
if ($route === '/api/short_links.php' || str_ends_with($route, '/api/short_links.php')) {
require __DIR__ . '/api/short_links.php';
exit;
}
if ($route === '/api/rbac.php' || str_ends_with($route, '/api/rbac.php')) {
require __DIR__ . '/api/rbac.php';
exit;
}
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
require __DIR__ . '/api/ticket_attachment.php';
exit;
}
if ($route === '/api/hub_deploy_docs.php' || str_ends_with($route, '/api/hub_deploy_docs.php')) {
require __DIR__ . '/api/hub_deploy_docs.php';
exit;
}
if ($route === '/api/tag_catalog.php' || str_ends_with($route, '/api/tag_catalog.php')) {
require __DIR__ . '/api/tag_catalog.php';
exit;
}
if ($route === '/api/auth_register.php' || str_ends_with($route, '/api/auth_register.php')) {
require __DIR__ . '/api/auth_register.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . Auth::authUrl('/login'));
exit;
}
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim($_POST['username'] ?? '');
$pass = $_POST['password'] ?? '';
$result = Auth::login($user, $pass);
if ($result === true) {
header('Location: ' . $base . '/');
exit;
}
if ($result === 'pending_2fa') {
header('Location: ' . Auth::authUrl('/two-factor'));
exit;
}
$loginError = 'Invalid credentials';
$st = Database::pdo()->prepare('SELECT status FROM users WHERE username = ? LIMIT 1');
$st->execute([$user]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (is_array($row) && ($row['status'] ?? '') === 'pending') {
$loginError = 'Verify your email before signing in.';
} elseif (AuthAttempts::isRateLimited($user)) {
$loginError = 'Too many attempts — try again later or ask an admin to clear lockouts.';
}
require __DIR__ . '/../views/login.php';
exit;
}
if ($route === '/login') {
if (Auth::pending2faUserId() > 0) {
header('Location: ' . Auth::authUrl('/two-factor'));
exit;
}
require __DIR__ . '/../views/login.php';
exit;
}
if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$registerEmail = $email;
$registerUsername = $username;
$out = AuthRegistration::register($email, $password, $username);
if ($out['ok']) {
$registerSuccess = 'Check your email for a verification link.';
require __DIR__ . '/../views/register.php';
exit;
}
$registerError = match ($out['error'] ?? '') {
'invalid_email' => 'Enter a valid email address.',
'weak_password' => 'Password must be at least 10 characters.',
'already_registered' => 'An account with this email or username already exists.',
'rate_limited' => 'Too many attempts — try again later.',
default => 'Registration failed.',
};
require __DIR__ . '/../views/register.php';
exit;
}
if ($route === '/register') {
require __DIR__ . '/../views/register.php';
exit;
}
if ($route === '/verify-email') {
$token = trim($_GET['token'] ?? '');
$result = AuthRegistration::verifyEmailToken($token);
$verifyOk = $result['ok'] ?? false;
if (!$verifyOk) {
$verifyError = match ($result['error'] ?? '') {
'expired_token' => 'This link has expired.',
'invalid_token' => 'Invalid verification link.',
default => 'Verification failed.',
};
}
require __DIR__ . '/../views/verify_email.php';
exit;
}
if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (Auth::pending2faUserId() <= 0) {
header('Location: ' . Auth::authUrl('/login'));
exit;
}
$code = trim($_POST['code'] ?? '');
if (Auth::completeTotpLogin($code)) {
header('Location: ' . $base . '/');
exit;
}
$twofaError = 'Invalid code';
require __DIR__ . '/../views/two_factor_challenge.php';
exit;
}
if ($route === '/two-factor') {
if (Auth::pending2faUserId() <= 0) {
header('Location: ' . Auth::authUrl('/login'));
exit;
}
$twofaQr = AuthTwoFactorPage::qrPayload();
require __DIR__ . '/../views/two_factor_challenge.php';
exit;
}
if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::check();
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$action = (string) ($_POST['action'] ?? '');
if ($action === 'start_totp') {
if (AuthFactors::hasTotp($uid)) {
$securityError = 'Remove the current authenticator before enrolling a new one.';
} else {
$secret = AuthTotp::generateSecret();
$_SESSION['totp_enroll_secret'] = $secret;
$_SESSION['totp_enroll_exp'] = time() + 900;
}
} elseif ($action === 'confirm_totp') {
if (AuthFactors::hasTotp($uid)) {
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
$securityError = 'Authenticator is already enrolled.';
} else {
$secret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
$code = trim($_POST['code'] ?? '');
if ($secret === '' || $exp < time()) {
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
$securityError = 'Setup expired — start again.';
} elseif (!AuthTotp::verify($secret, $code, 2)) {
$securityError = 'Invalid code — check device time and try again.';
} else {
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
AuthFactors::enrollTotp($uid, $secret);
$securitySuccess = 'Authenticator enrolled.';
}
}
} elseif ($action === 'remove_totp') {
AuthFactors::removeTotp($uid);
$securitySuccess = 'Authenticator removed.';
}
if (!empty($securityError)) {
$_SESSION['security_flash_error'] = $securityError;
}
if (!empty($securitySuccess)) {
$_SESSION['security_flash_success'] = $securitySuccess;
}
header('Location: ' . Auth::basePath() . '/account-security', true, 303);
exit;
}
if ($route === '/account-security') {
Auth::check();
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$securityError = (string) ($_SESSION['security_flash_error'] ?? '');
$securitySuccess = (string) ($_SESSION['security_flash_success'] ?? '');
unset($_SESSION['security_flash_error'], $_SESSION['security_flash_success']);
$totpSecret = '';
if (!AuthFactors::hasTotp($uid)) {
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
if ($exp >= time()) {
$totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
}
}
require __DIR__ . '/../views/account_security.php';
exit;
}
if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) {
$_GET['view'] = 'graphs';
$route = '/';
}
Auth::check();
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
$view = $_GET['view'] ?? 'home';
if ($view === 'short_links' && !Rbac::can('short_links_view')) {
http_response_code(403);
echo 'Forbidden';
exit;
}
if ($view === 'report' && isset($_GET['id'])) {
try {
$report = ReportRepository::getById((int) $_GET['id']);
if (!$report) {
http_response_code(404);
echo 'Not found';
exit;
}
$uid = (int) (Auth::user()['id'] ?? 0);
if ($uid > 0) {
try {
ReportRepository::markViewed((int) $report['id'], $uid);
} catch (Throwable $e) {
error_log('markViewed: ' . $e->getMessage());
}
}
$pageTitle = 'Report';
require __DIR__ . '/../views/layout.php';
} catch (Throwable $e) {
error_log('report view: ' . $e->getMessage());
http_response_code(500);
if (cfg('debug')) {
header('Content-Type: text/plain; charset=utf-8');
echo 'Report view failed: ' . $e->getMessage();
} else {
echo 'Report view failed';
}
}
exit;
}
if ($view === 'ticket' && isset($_GET['id'])) {
try {
Database::requireTicketsTable();
$ticket = TicketRepository::getById((int) $_GET['id']);
if (!$ticket) {
http_response_code(404);
echo 'Not found';
exit;
}
$pageTitle = 'Ticket';
require __DIR__ . '/../views/layout.php';
} catch (Throwable $e) {
error_log('ticket view: ' . $e->getMessage());
http_response_code(500);
echo cfg('debug') ? 'Ticket view failed: ' . $e->getMessage() : 'Ticket view failed';
}
exit;
}
$pageTitle = match ($view) {
'home' => 'Home',
'tickets' => 'Tickets',
'graphs' => 'Analytics',
'live_sessions' => 'Live sessions',
'remote_access' => 'Remote access',
'short_links' => 'Short links',
'rbac' => 'Access control',
'reports', 'report' => 'Issues',
default => 'Console',
};
require __DIR__ . '/../views/layout.php';