1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 07:37:47 +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);
/** Emits GA4 config + shared analytics.js for PHP views. */
final class AnalyticsHead {
public static function render(string $platform = 'crashes'): void {
$enabled = (bool) cfg('analytics.enabled', false);
$measurementId = trim((string) cfg('analytics.measurement_id', ''));
if (!$enabled || $measurementId === '') {
return;
}
$basePath = Auth::basePath();
$config = [
'measurementId' => $measurementId,
'platform' => $platform,
'basePath' => $basePath,
'debug' => (bool) cfg('analytics.debug', false),
];
$json = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP);
if ($json === false) {
return;
}
echo '<script>window.ANDROIDCAST_ANALYTICS=', $json, ';</script>', "\n";
echo '<script src="', h($basePath), '/assets/js/analytics.js" defer></script>', "\n";
}
}

View File

@@ -0,0 +1,175 @@
<?php
/*
* package examples/crash_reporter/backend/src/Auth.php
* Auth.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, 56 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 a6e49e8d07788ac2b4ea48f886f9a46ec6f5c1eab5b9723e1554e1fe53765efd
*/
declare(strict_types=1);
final class Auth {
public static function user(): ?array {
$u = $_SESSION['user'] ?? null;
if (!$u || !empty($u['companies'])) {
return $u;
}
$uid = (int) ($u['id'] ?? 0);
if ($uid <= 0) {
return $u;
}
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$uid]);
$row = $stmt->fetch();
if (!$row) {
return $u;
}
Database::ensureRbacSchema();
Rbac::seedMembershipsForUser($uid, (string) ($row['role'] ?? 'viewer'));
$_SESSION['user'] = Rbac::buildSessionUser($row);
return $_SESSION['user'];
}
public static function check(): void {
if (!self::user()) {
header('Location: ' . self::authUrl('/login'));
exit;
}
}
public static function can(string $action): bool {
return Rbac::can($action);
}
public static function canEditTags(?int $companyId = null): bool {
return Rbac::can('edit_tags', $companyId);
}
/** Ticket owner or company admin+ (same gate as tag edit for alpha). */
public static function canEditTicket(array $ticket, ?int $companyId = null): bool {
if (self::canEditTags($companyId)) {
return true;
}
$user = self::user();
if (!$user) {
return false;
}
$owner = trim((string) ($ticket['owner_username'] ?? ''));
return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0;
}
/**
* @return true|'pending_2fa'|false
*/
public static function login(string $username, string $password): bool|string {
if (AuthAttempts::isRateLimited($username)) {
return false;
}
$pdo = Database::pdo();
AuthEmailSchema::ensure($pdo);
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
$stmt->execute([$username]);
$row = $stmt->fetch();
if (!$row || !password_verify($password, $row['password_hash'])) {
AuthAttempts::record('login_fail', $username);
return false;
}
$status = (string) ($row['status'] ?? 'active');
if ($status === 'pending') {
return false;
}
if ($status === 'locked' || $status === 'disabled') {
AuthAttempts::record('login_fail', $username);
return false;
}
$uid = (int) ($row['id'] ?? 0);
if ($uid > 0 && AuthFactors::hasTotp($uid)) {
$_SESSION['pending_2fa_user_id'] = $uid;
$_SESSION['pending_2fa_exp'] = time() + 300;
AuthAttempts::record('login_ok', $username);
return 'pending_2fa';
}
$_SESSION['user'] = Rbac::buildSessionUser($row);
AuthAttempts::record('login_ok', $username);
return true;
}
public static function completeTotpLogin(string $code): bool {
$uid = (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
$exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0);
if ($uid <= 0 || $exp < time()) {
self::clearPending2fa();
return false;
}
$secret = AuthFactors::getTotpSecret($uid);
if ($secret === null || !AuthTotp::verify($secret, $code)) {
return false;
}
self::clearPending2fa();
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$uid]);
$row = $stmt->fetch();
if (!$row) {
return false;
}
$_SESSION['user'] = Rbac::buildSessionUser($row);
return true;
}
public static function pending2faUserId(): int {
$exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0);
if ($exp < time()) {
return 0;
}
return (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
}
public static function clearPending2fa(): void {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']);
}
public static function logout(): void {
self::clearPending2fa();
unset($_SESSION['user']);
}
public static function basePath(): string {
$bp = rtrim((string) cfg('base_path', ''), '/');
if ($bp === '') {
return $bp;
}
// If nginx serves /issues/ but config.php still has legacy /crashes, asset URLs must match nginx.
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
if (str_contains($uri, '/issues') && str_ends_with($bp, '/crashes')) {
return substr($bp, 0, -strlen('/crashes')) . '/issues';
}
return $bp;
}
/** Shared project prefix, e.g. /app/androidcast_project */
public static function projectBasePath(): string {
$p = trim((string) cfg('project_base_path', ''));
if ($p !== '') {
return rtrim($p, '/');
}
$bp = self::basePath();
if (preg_match('#^(.+)/(?:crashes|issues|graphs|build)$#', $bp, $m)) {
return $m[1];
}
return '/app/androidcast_project';
}
/** Login/logout/register/2FA live at project root, not under /issues/. */
public static function authUrl(string $path = ''): string {
$root = self::projectBasePath();
$path = '/' . ltrim($path, '/');
if ($path === '/' || $path === '') {
return $root;
}
return $root . $path;
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/** Hashed login/register attempts + rate limit (task 3.x / A6). */
final class AuthAttempts {
private function __construct() {
}
public static function ensureSchema(): void {
AuthEmailSchema::ensure(Database::pdo());
}
public static function record(string $outcome, ?string $username = null): void {
self::ensureSchema();
$ipHash = self::hashIp(self::clientIp());
$userHash = $username !== null && $username !== '' ? self::hashUsername($username) : null;
Database::pdo()->prepare(
'INSERT INTO auth_attempts (ip_hash, username_hash, outcome, created_at) VALUES (?, ?, ?, ?)'
)->execute([$ipHash, $userHash, $outcome, date('Y-m-d H:i:s')]);
}
public static function isRateLimited(?string $username): bool {
self::ensureSchema();
$max = max(3, (int) cfg('auth.max_attempts', 8));
$windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60);
$since = date('Y-m-d H:i:s', time() - $windowSec);
$pdo = Database::pdo();
$ipHash = self::hashIp(self::clientIp());
$st = $pdo->prepare(
"SELECT COUNT(*) FROM auth_attempts WHERE ip_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?"
);
$st->execute([$ipHash, $since]);
if ((int) $st->fetchColumn() >= $max) {
return true;
}
if ($username !== null && $username !== '') {
$userHash = self::hashUsername($username);
$st = $pdo->prepare(
"SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?"
);
$st->execute([$userHash, $since]);
if ((int) $st->fetchColumn() >= $max) {
return true;
}
}
return false;
}
public static function recentFailureCount(string $username): int {
self::ensureSchema();
$windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60);
$since = date('Y-m-d H:i:s', time() - $windowSec);
$st = Database::pdo()->prepare(
"SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?"
);
$st->execute([self::hashUsername($username), $since]);
return (int) $st->fetchColumn();
}
public static function clearForUser(int $userId): int {
self::ensureSchema();
$st = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1');
$st->execute([$userId]);
$username = $st->fetchColumn();
if (!is_string($username) || $username === '') {
return 0;
}
return self::clearForUsername($username);
}
public static function clearForUsername(string $username): int {
self::ensureSchema();
$hash = self::hashUsername($username);
$st = Database::pdo()->prepare('DELETE FROM auth_attempts WHERE username_hash = ?');
$st->execute([$hash]);
$deleted = $st->rowCount();
$pdo = Database::pdo();
$pdo->prepare(
"UPDATE users SET status = 'active' WHERE username = ? AND status = 'locked'"
)->execute([$username]);
return $deleted;
}
public static function clientIp(): string {
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if (is_string($xff) && $xff !== '') {
$parts = explode(',', $xff);
return trim($parts[0]);
}
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
public static function hashIp(string $ip): string {
return hash_hmac('sha256', trim($ip), self::pepper());
}
public static function hashUsername(string $username): string {
return hash_hmac('sha256', strtolower(trim($username)), self::pepper());
}
private static function pepper(): string {
$p = (string) cfg('auth.encryption_key', '');
if ($p === '') {
$p = 'dev-change-me-set-auth.encryption_key-in-config.php';
}
return $p;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/** Encrypt TOTP secrets at rest (libsodium preferred). */
final class AuthCrypto {
private function __construct() {
}
public static function encrypt(string $plain): string {
if ($plain === '') {
return '';
}
$key = self::key();
if (function_exists('sodium_crypto_secretbox')) {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plain, $nonce, $key);
return 'sbx:' . base64_encode($nonce . $cipher);
}
$iv = random_bytes(16);
$tag = '';
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($cipher === false) {
return 'b64:' . base64_encode($plain);
}
return 'gcm:' . base64_encode($iv . $tag . $cipher);
}
public static function decrypt(string $stored): string {
if ($stored === '') {
return '';
}
if (str_starts_with($stored, 'sbx:')) {
$raw = base64_decode(substr($stored, 4), true);
if ($raw === false || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES) {
return '';
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, self::key());
return $plain === false ? '' : $plain;
}
if (str_starts_with($stored, 'gcm:')) {
$raw = base64_decode(substr($stored, 4), true);
if ($raw === false || strlen($raw) < 32) {
return '';
}
$iv = substr($raw, 0, 16);
$tag = substr($raw, 16, 16);
$cipher = substr($raw, 32);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $iv, $tag);
return is_string($plain) ? $plain : '';
}
if (str_starts_with($stored, 'b64:')) {
$v = base64_decode(substr($stored, 4), true);
return is_string($v) ? $v : '';
}
return '';
}
/** @return string 32-byte key */
private static function key(): string {
$pepper = (string) cfg('auth.encryption_key', '');
if ($pepper === '') {
$pepper = 'dev-change-me-set-auth.encryption_key-in-config.php';
}
return hash('sha256', $pepper, true);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
/** Migration 008 — email registration + 2FA tables (SQLite dev + MariaDB ALTER). */
final class AuthEmailSchema {
private static bool $ensured = false;
private function __construct() {
}
public static function ensure(PDO $pdo): void {
if (self::$ensured) {
return;
}
if (!Database::tableExists($pdo, 'users')) {
self::$ensured = true;
return;
}
Database::withMigrationLock($pdo, 'auth_email_schema', static function () use ($pdo): void {
self::ensureUserColumns($pdo);
self::ensureAuthTokens($pdo);
self::ensureAuthFactors($pdo);
self::ensureAuthAttempts($pdo);
});
self::$ensured = true;
}
private static function ensureUserColumns(PDO $pdo): void {
$existing = Database::columnNames($pdo, 'users');
$cols = [
'email_normalized' => Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL',
'email_verified_at' => Database::isMysql() ? 'TIMESTAMP NULL' : 'TEXT NULL',
'status' => Database::isMysql()
? "ENUM('pending','active','locked','disabled') NOT NULL DEFAULT 'active'"
: "TEXT NOT NULL DEFAULT 'active'",
'recovery_email_normalized' => Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL',
];
foreach ($cols as $name => $ddl) {
if (in_array($name, $existing, true)) {
continue;
}
$pdo->exec('ALTER TABLE users ADD COLUMN ' . $name . ' ' . $ddl);
$existing[] = $name;
}
}
private static function ensureAuthTokens(PDO $pdo): void {
if (Database::tableExists($pdo, 'auth_tokens')) {
return;
}
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE auth_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NULL,
purpose ENUM('verify_email','reset_password','login_magic') NOT NULL,
token_hash CHAR(64) NOT NULL,
email_normalized VARCHAR(254) NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_auth_tokens_hash (token_hash),
KEY idx_auth_tokens_email (email_normalized)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE auth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NULL,
purpose TEXT NOT NULL,
token_hash TEXT NOT NULL,
email_normalized TEXT NULL,
expires_at TEXT NOT NULL,
used_at TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens (token_hash)');
}
private static function ensureAuthFactors(PDO $pdo): void {
if (Database::tableExists($pdo, 'auth_factors')) {
return;
}
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE auth_factors (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
type ENUM('totp','webauthn','backup_code') NOT NULL,
secret_encrypted TEXT NULL,
label VARCHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_auth_factors_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE auth_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
secret_encrypted TEXT NULL,
label TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
private static function ensureAuthAttempts(PDO $pdo): void {
if (Database::tableExists($pdo, 'auth_attempts')) {
return;
}
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE auth_attempts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ip_hash CHAR(64) NOT NULL,
username_hash CHAR(64) NULL,
outcome VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_auth_attempts_ip (ip_hash, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE auth_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_hash TEXT NOT NULL,
username_hash TEXT NULL,
outcome TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/** User 2FA factors (TOTP alpha). */
final class AuthFactors {
private function __construct() {
}
public static function ensureSchema(): void {
AuthEmailSchema::ensure(Database::pdo());
}
public static function hasTotp(int $userId): bool {
if ($userId <= 0) {
return false;
}
self::ensureSchema();
$st = Database::pdo()->prepare(
"SELECT id FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1"
);
$st->execute([$userId]);
return (bool) $st->fetchColumn();
}
public static function getTotpSecret(int $userId): ?string {
if ($userId <= 0) {
return null;
}
self::ensureSchema();
$st = Database::pdo()->prepare(
"SELECT secret_encrypted FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1"
);
$st->execute([$userId]);
$enc = $st->fetchColumn();
if (!is_string($enc) || $enc === '') {
return null;
}
$plain = AuthCrypto::decrypt($enc);
return $plain !== '' ? $plain : null;
}
public static function enrollTotp(int $userId, string $secret): void {
self::ensureSchema();
$enc = AuthCrypto::encrypt($secret);
$pdo = Database::pdo();
$pdo->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]);
$pdo->prepare(
'INSERT INTO auth_factors (user_id, type, secret_encrypted, label, created_at) VALUES (?, ?, ?, ?, ?)'
)->execute([$userId, 'totp', $enc, 'Authenticator', date('Y-m-d H:i:s')]);
}
public static function removeTotp(int $userId): void {
self::ensureSchema();
Database::pdo()->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]);
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/** Outbound mail (registration verify, password reset). Task 2.5 — uses config mail.* */
final class AuthMailer {
private function __construct() {
}
public static function send(string $to, string $subject, string $bodyText): bool {
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
$replyTo = (string) cfg('mail.reply_to', '');
$transport = strtolower((string) cfg('mail.transport', 'smtp'));
if ($transport === 'sendmail') {
return self::sendMail($to, $subject, $bodyText, $from, $replyTo);
}
return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo);
}
public static function sendVerifyEmail(string $to, string $verifyUrl): bool {
$subject = 'Verify your Android Cast account';
$body = "Open this link to verify your email (valid 24h):\n\n" . $verifyUrl . "\n";
return self::send($to, $subject, $body);
}
private static function sendMail(string $to, string $subject, string $body, string $from, string $replyTo): bool {
$headers = "From: {$from}\r\n";
if ($replyTo !== '') {
$headers .= "Reply-To: {$replyTo}\r\n";
}
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
return @mail($to, $subject, $body, $headers);
}
private static function sendSmtp(string $to, string $subject, string $body, string $from, string $replyTo): bool {
$host = (string) cfg('mail.smtp.host', '127.0.0.1');
$port = (int) cfg('mail.smtp.port', 587);
$enc = strtolower((string) cfg('mail.smtp.encryption', 'tls'));
$user = (string) cfg('mail.smtp.username', '');
$pass = (string) cfg('mail.smtp.password', '');
$remote = ($enc === 'ssl' ? 'ssl://' : '') . $host;
$errno = 0;
$errstr = '';
$fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15);
if ($fp === false) {
error_log('AuthMailer SMTP connect failed: ' . $errstr);
return self::sendMail($to, $subject, $body, $from, $replyTo);
}
stream_set_timeout($fp, 15);
if (!self::smtpExpect($fp, [220])) {
fclose($fp);
return false;
}
$ehloHost = 'localhost';
fwrite($fp, "EHLO {$ehloHost}\r\n");
if (!self::smtpExpect($fp, [250])) {
fclose($fp);
return false;
}
if ($enc === 'tls') {
fwrite($fp, "STARTTLS\r\n");
if (!self::smtpExpect($fp, [220])) {
fclose($fp);
return false;
}
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($fp);
return false;
}
fwrite($fp, "EHLO {$ehloHost}\r\n");
if (!self::smtpExpect($fp, [250])) {
fclose($fp);
return false;
}
}
if ($user !== '') {
fwrite($fp, "AUTH LOGIN\r\n");
if (!self::smtpExpect($fp, [334])) {
fclose($fp);
return false;
}
fwrite($fp, base64_encode($user) . "\r\n");
if (!self::smtpExpect($fp, [334])) {
fclose($fp);
return false;
}
fwrite($fp, base64_encode($pass) . "\r\n");
if (!self::smtpExpect($fp, [235])) {
fclose($fp);
return false;
}
}
$fromAddr = self::extractAddress($from);
fwrite($fp, "MAIL FROM:<{$fromAddr}>\r\n");
if (!self::smtpExpect($fp, [250])) {
fclose($fp);
return false;
}
fwrite($fp, "RCPT TO:<{$to}>\r\n");
if (!self::smtpExpect($fp, [250, 251])) {
fclose($fp);
return false;
}
fwrite($fp, "DATA\r\n");
if (!self::smtpExpect($fp, [354])) {
fclose($fp);
return false;
}
$msg = "From: {$from}\r\n";
if ($replyTo !== '') {
$msg .= "Reply-To: {$replyTo}\r\n";
}
$msg .= "To: {$to}\r\nSubject: {$subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n";
fwrite($fp, $msg);
if (!self::smtpExpect($fp, [250])) {
fclose($fp);
return false;
}
fwrite($fp, "QUIT\r\n");
fclose($fp);
return true;
}
/** @param resource $fp @param list<int> $codes */
private static function smtpExpect($fp, array $codes): bool {
$line = '';
while (($chunk = fgets($fp, 512)) !== false) {
$line .= $chunk;
if (strlen($chunk) >= 4 && $chunk[3] === ' ') {
break;
}
}
$code = (int) substr(trim($line), 0, 3);
return in_array($code, $codes, true);
}
private static function extractAddress(string $from): string {
if (preg_match('/<([^>]+)>/', $from, $m)) {
return trim($m[1]);
}
return trim($from);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */
final class AuthRegistration {
private const TOKEN_TTL_S = 86400;
private function __construct() {
}
public static function ensureSchema(): void {
AuthEmailSchema::ensure(Database::pdo());
}
/** @return array{ok:bool,error?:string} */
public static function register(string $email, string $password, string $username = ''): array {
self::ensureSchema();
if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) {
return ['ok' => false, 'error' => 'rate_limited'];
}
$emailNorm = self::normalizeEmail($email);
if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
AuthAttempts::record('register_fail', $username ?: $email);
return ['ok' => false, 'error' => 'invalid_email'];
}
if (strlen($password) < 10) {
AuthAttempts::record('register_fail', $username ?: $email);
return ['ok' => false, 'error' => 'weak_password'];
}
$username = trim($username);
if ($username === '') {
$username = strstr($emailNorm, '@', true) ?: $emailNorm;
}
$pdo = Database::pdo();
$st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1');
$st->execute([$emailNorm, $username]);
if ($st->fetchColumn()) {
AuthAttempts::record('register_fail', $username);
return ['ok' => false, 'error' => 'already_registered'];
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$pdo->prepare(
'INSERT INTO users (username, password_hash, role, email_normalized, status) VALUES (?, ?, ?, ?, ?)'
)->execute([
$username,
$hash,
'viewer',
$emailNorm,
'pending',
]);
$userId = (int) $pdo->lastInsertId();
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S);
$pdo->prepare(
'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)'
)->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]);
$base = rtrim((string) cfg('base_path', ''), '/');
$origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/');
$verifyUrl = $origin . $base . '/verify-email?token=' . urlencode($token);
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) {
error_log('AuthRegistration: verify mail failed for ' . $emailNorm);
}
AuthAttempts::record('register_ok', $username);
return ['ok' => true];
}
/** @return array{ok:bool,error?:string} */
public static function verifyEmailToken(string $token): array {
self::ensureSchema();
$token = trim($token);
if ($token === '') {
return ['ok' => false, 'error' => 'missing_token'];
}
$hash = hash('sha256', $token);
$pdo = Database::pdo();
$st = $pdo->prepare(
"SELECT * FROM auth_tokens WHERE token_hash = ? AND purpose = 'verify_email' AND used_at IS NULL LIMIT 1"
);
$st->execute([$hash]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return ['ok' => false, 'error' => 'invalid_token'];
}
$exp = strtotime((string) ($row['expires_at'] ?? ''));
if ($exp !== false && $exp < time()) {
return ['ok' => false, 'error' => 'expired_token'];
}
$userId = (int) ($row['user_id'] ?? 0);
$now = date('Y-m-d H:i:s');
$pdo->prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?')->execute([$now, (int) $row['id']]);
$pdo->prepare(
"UPDATE users SET email_verified_at = ?, status = 'active' WHERE id = ?"
)->execute([$now, $userId]);
return ['ok' => true];
}
private static function normalizeEmail(string $email): string {
return strtolower(trim($email));
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/** RFC 6238 TOTP (6 digits, 30s) — no external deps. */
final class AuthTotp {
private const PERIOD = 30;
private const DIGITS = 6;
private function __construct() {
}
public static function generateSecret(int $bytes = 20): string {
return self::base32Encode(random_bytes($bytes));
}
public static function provisioningUri(string $secret, string $account, string $issuer): string {
$label = rawurlencode($issuer . ':' . $account);
$issuerEnc = rawurlencode($issuer);
return 'otpauth://totp/' . $label . '?secret=' . $secret . '&issuer=' . $issuerEnc . '&period=' . self::PERIOD;
}
public static function qrImageUrl(string $otpauthUri, int $size = 200): string {
return 'https://api.qrserver.com/v1/create-qr-code/?size=' . $size . 'x' . $size
. '&data=' . rawurlencode($otpauthUri);
}
public static function verify(string $secret, string $code, int $window = 2): bool {
$code = preg_replace('/\s+/', '', $code) ?? '';
if (!preg_match('/^\d{6}$/', $code)) {
return false;
}
$slice = (int) floor(time() / self::PERIOD);
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(self::codeAt($secret, $slice + $i), $code)) {
return true;
}
}
return false;
}
public static function codeAt(string $secret, int $timeSlice): string {
$key = self::base32Decode($secret);
$time = pack('N*', 0, $timeSlice);
$hash = hash_hmac('sha1', $time, $key, true);
$offset = ord($hash[19]) & 0x0f;
$truncated = (
((ord($hash[$offset]) & 0x7f) << 24)
| ((ord($hash[$offset + 1]) & 0xff) << 16)
| ((ord($hash[$offset + 2]) & 0xff) << 8)
| (ord($hash[$offset + 3]) & 0xff)
);
$mod = 10 ** self::DIGITS;
return str_pad((string) ($truncated % $mod), self::DIGITS, '0', STR_PAD_LEFT);
}
private static function base32Encode(string $data): string {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
foreach (str_split($data) as $ch) {
$bits .= str_pad(decbin(ord($ch)), 8, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 5) as $chunk) {
if (strlen($chunk) < 5) {
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
}
$out .= $alphabet[bindec($chunk)];
}
return $out;
}
private static function base32Decode(string $secret): string {
$secret = strtoupper(preg_replace('/[^A-Z2-7]/', '', $secret) ?? '');
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
$len = strlen($secret);
for ($i = 0; $i < $len; $i++) {
$pos = strpos($alphabet, $secret[$i]);
if ($pos === false) {
continue;
}
$bits .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 8) as $chunk) {
if (strlen($chunk) < 8) {
break;
}
$out .= chr(bindec($chunk));
}
return $out;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/** 2FA challenge page helpers (QR short link for mobile handoff). */
final class AuthTwoFactorPage {
/** @return array{short_url?:string,qr_url?:string,long_url:string}|null */
public static function qrPayload(): ?array {
$uid = Auth::pending2faUserId();
if ($uid <= 0) {
return null;
}
$project = Auth::projectBasePath();
$longUrl = 'https://apps.f0xx.org' . $project . '/?ref=2fa&uid=' . $uid;
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
return ['long_url' => $longUrl];
}
$bearers = ShortLinksRepository::listBearers();
if ($bearers === []) {
return ['long_url' => $longUrl];
}
$bearerId = (int) ($bearers[0]['id'] ?? 0);
if ($bearerId <= 0) {
return ['long_url' => $longUrl];
}
$mint = ShortLinksRepository::createLink($bearerId, $longUrl, 900);
if (empty($mint['ok'])) {
return ['long_url' => $longUrl];
}
return [
'long_url' => $longUrl,
'short_url' => (string) ($mint['short_url'] ?? ''),
'qr_url' => (string) ($mint['qr_url'] ?? ''),
];
}
}

View File

@@ -0,0 +1,669 @@
<?php
declare(strict_types=1);
final class Database {
private static ?PDO $pdo = null;
private static bool $schemaChecked = false;
private static ?string $driver = null;
public static function driver(): string {
if (self::$driver !== null) {
return self::$driver;
}
$d = cfg('db.driver', 'sqlite');
self::$driver = ($d === 'mysql' || $d === 'mariadb') ? 'mysql' : 'sqlite';
return self::$driver;
}
public static function isMysql(): bool {
return self::driver() === 'mysql';
}
public static function pdo(): PDO {
if (self::$pdo !== null) {
return self::$pdo;
}
if (self::isMysql()) {
$m = cfg('db.mysql', []);
$db = $m['database'] ?? 'androidcast_crashes';
$charset = $m['charset'] ?? 'utf8mb4';
$socket = trim((string) ($m['socket'] ?? ''));
// Prefer explicit socket from config (Alpine: TCP 3306 often closed).
if ($socket !== '') {
$dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset);
} else {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$m['host'] ?? '127.0.0.1',
(int) ($m['port'] ?? 3306),
$db,
$charset
);
}
self::$pdo = new PDO($dsn, $m['username'] ?? '', $m['password'] ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
self::ensureSchema();
return self::$pdo;
}
$path = cfg('db.sqlite_path');
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
self::$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
self::$pdo->exec('PRAGMA foreign_keys = ON');
self::ensureSchema();
return self::$pdo;
}
/** Upsert into report_views (portable). */
public static function upsertReportView(int $userId, int $reportId, int $viewedAtMs): void {
$pdo = self::pdo();
if (self::isMysql()) {
$sql = 'INSERT INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE viewed_at_ms = VALUES(viewed_at_ms)';
} else {
$sql = 'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$userId, $reportId, $viewedAtMs]);
}
/** Idempotent schema setup for sqlite and mysql. */
public static function ensureSchema(): void {
if (self::$schemaChecked) {
return;
}
self::$schemaChecked = true;
$pdo = self::$pdo;
if ($pdo === null) {
return;
}
if (self::isMysql()) {
self::ensureMysqlSchema($pdo);
return;
}
self::ensureSqliteSchema($pdo);
}
private static function ensureSqliteSchema(PDO $pdo): void {
$need = static function (string $table) use ($pdo): bool {
return !self::tableExists($pdo, $table);
};
if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) {
self::ensureReportColumns($pdo);
self::ensureReportListIndexes($pdo);
self::ensureReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
AuthEmailSchema::ensure($pdo);
return;
}
$schemaFile = __DIR__ . '/../sql/schema.sqlite.sql';
if (!is_readable($schemaFile)) {
throw new RuntimeException('schema file missing: ' . $schemaFile);
}
if (!$need('reports') && !self::reportsSchemaOk($pdo)) {
$pdo->exec('DROP TABLE IF EXISTS reports');
}
$pdo->exec((string) file_get_contents($schemaFile));
self::ensureReportColumns($pdo);
self::ensureReportListIndexes($pdo);
self::ensureReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
AuthEmailSchema::ensure($pdo);
}
private static function ensureMysqlSchema(PDO $pdo): void {
if (!self::tableExists($pdo, 'users')) {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$pdo->exec(
"INSERT IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '\$2y\$10\$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root')"
);
}
if (!self::tableExists($pdo, 'reports')) {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
report_id VARCHAR(128) NOT NULL,
fingerprint VARCHAR(64) NOT NULL,
crash_type VARCHAR(32) NOT NULL,
generated_at_ms BIGINT NOT NULL,
received_at_ms BIGINT NOT NULL,
device_model VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
payload_json LONGTEXT NOT NULL,
tags_json LONGTEXT NOT NULL,
UNIQUE KEY uq_reports_report_id (report_id),
KEY idx_reports_generated (generated_at_ms),
KEY idx_reports_received (received_at_ms),
KEY idx_reports_fingerprint (fingerprint),
KEY idx_reports_fp_received (fingerprint, received_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
} else {
self::ensureMysqlReportColumns($pdo);
}
self::ensureReportListIndexes($pdo);
self::ensureMysqlReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
AuthEmailSchema::ensure($pdo);
self::ensureTicketsTable($pdo);
}
/** Active PDO; opens the pool when callers have not touched the DB yet (e.g. cached Auth session). */
private static function connection(?PDO $pdo = null): PDO {
return $pdo ?? self::$pdo ?? self::pdo();
}
public static function ticketsTableExists(?PDO $pdo = null): bool {
return self::tableExists(self::connection($pdo), 'tickets');
}
/** @throws RuntimeException when `tickets` is missing (MariaDB needs root migration). */
public static function requireTicketsTable(?PDO $pdo = null): void {
$pdo = self::connection($pdo);
self::ensureTicketsTable($pdo);
if (!self::tableExists($pdo, 'tickets')) {
throw new RuntimeException(self::ticketsMigrationHint());
}
}
public static function ticketsMigrationHint(): string {
return 'Table tickets is missing. On MariaDB run as MySQL root: '
. 'mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql '
. '(or ./scripts/migrate-003-tickets.sh from backend/)';
}
public static function ensureTicketsTable(?PDO $pdo = null): void {
$pdo = self::connection($pdo);
if (self::tableExists($pdo, 'tickets')) {
return;
}
$ok = false;
if (self::isMysql()) {
$sql = file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sql') ?: '';
$sql = preg_replace('/^\s*--.*$/m', '', $sql) ?? $sql;
$sql = preg_replace('/^\s*USE\s+[^;]+;\s*/mi', '', $sql) ?? $sql;
$ok = self::execSchemaSql($pdo, trim($sql), 'create tickets mysql');
} else {
$ok = self::execSchemaSql(
$pdo,
file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '',
'create tickets sqlite'
);
}
if (!$ok && !self::tableExists($pdo, 'tickets')) {
error_log('crash_reporter: ' . self::ticketsMigrationHint());
if (function_exists('log_crash_event')) {
log_crash_event('schema', self::ticketsMigrationHint());
}
}
}
/**
* Run DDL; app DB user often has no ALTER — log and continue (run sql/migrations as root).
*/
private static function execSchemaSql(PDO $pdo, string $sql, string $label): bool {
try {
$pdo->exec($sql);
return true;
} catch (PDOException $e) {
$msg = $label . ': ' . $e->getMessage();
error_log('crash_reporter schema ' . $msg);
if (function_exists('log_crash_event')) {
log_crash_event('schema', $msg);
}
return false;
}
}
public static function reportsHaveRbacColumns(?PDO $pdo = null): bool {
$pdo ??= self::$pdo;
if ($pdo === null || !self::tableExists($pdo, 'reports')) {
return false;
}
$names = self::columnNames($pdo, 'reports');
return in_array('company_id', $names, true);
}
/** Phase 1 RBAC tables + report company/device columns (idempotent). */
public static function ensureRbacSchema(?PDO $pdo = null): void {
$pdo ??= self::$pdo;
if ($pdo === null) {
return;
}
self::ensureCompaniesTable($pdo);
self::seedDefaultCompany($pdo);
self::ensureCompanyMembershipsTable($pdo);
self::ensureDevicesTable($pdo);
self::ensureReportCompanyColumns($pdo);
self::backfillCompanyMemberships($pdo);
if (!self::reportsHaveRbacColumns($pdo)) {
error_log(
'crash_reporter: reports.company_id missing — run as MySQL root: '
. 'mysql -u root -p androidcast_crashes < sql/migrations/002_reports_company_columns.sql'
);
}
}
private static function ensureCompaniesTable(PDO $pdo): void {
if (self::tableExists($pdo, 'companies')) {
return;
}
if (self::isMysql()) {
self::execSchemaSql(
$pdo,
"CREATE TABLE companies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_companies_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'create companies'
);
return;
}
self::execSchemaSql(
$pdo,
"CREATE TABLE companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
'create companies'
);
}
private static function seedDefaultCompany(PDO $pdo): void {
if (self::isMysql()) {
self::execSchemaSql(
$pdo,
"INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')",
'seed company'
);
return;
}
self::execSchemaSql(
$pdo,
"INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')",
'seed company'
);
}
private static function ensureCompanyMembershipsTable(PDO $pdo): void {
if (self::tableExists($pdo, 'company_memberships')) {
return;
}
if (self::isMysql()) {
self::execSchemaSql(
$pdo,
"CREATE TABLE company_memberships (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
company_id INT UNSIGNED NOT NULL,
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
permissions_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_membership_user_company (user_id, company_id),
KEY idx_membership_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'create company_memberships'
);
return;
}
self::execSchemaSql(
$pdo,
"CREATE TABLE company_memberships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
permissions_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, company_id)
)",
'create company_memberships'
);
}
private static function ensureDevicesTable(PDO $pdo): void {
if (self::tableExists($pdo, 'devices')) {
return;
}
if (self::isMysql()) {
self::execSchemaSql(
$pdo,
"CREATE TABLE devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id INT UNSIGNED NOT NULL,
external_id VARCHAR(128) NOT NULL,
display_name VARCHAR(256) NULL,
manufacturer VARCHAR(128) NULL,
model VARCHAR(128) NULL,
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
registered_at_ms BIGINT NOT NULL,
last_seen_at_ms BIGINT NOT NULL,
meta_json LONGTEXT NULL,
UNIQUE KEY uq_devices_company_external (company_id, external_id),
KEY idx_devices_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'create devices'
);
return;
}
self::execSchemaSql(
$pdo,
"CREATE TABLE devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
external_id TEXT NOT NULL,
display_name TEXT,
manufacturer TEXT,
model TEXT,
source TEXT NOT NULL DEFAULT 'auto',
registered_at_ms INTEGER NOT NULL,
last_seen_at_ms INTEGER NOT NULL,
meta_json TEXT,
UNIQUE (company_id, external_id)
)",
'create devices'
);
}
private static function ensureReportCompanyColumns(PDO $pdo): void {
if (!self::tableExists($pdo, 'reports')) {
return;
}
$names = self::columnNames($pdo, 'reports');
if (!in_array('company_id', $names, true)) {
if (self::isMysql()) {
self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD COLUMN company_id INT UNSIGNED NOT NULL DEFAULT 1',
'alter reports.company_id'
);
self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD KEY idx_reports_company (company_id)',
'index reports.company_id'
);
} else {
self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD COLUMN company_id INTEGER NOT NULL DEFAULT 1',
'alter reports.company_id'
);
}
self::execSchemaSql(
$pdo,
'UPDATE reports SET company_id = 1 WHERE company_id IS NULL OR company_id = 0',
'backfill reports.company_id'
);
}
$names = self::columnNames($pdo, 'reports');
if (!in_array('device_id', $names, true)) {
if (self::isMysql()) {
self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD COLUMN device_id BIGINT UNSIGNED NULL',
'alter reports.device_id'
);
self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD KEY idx_reports_device (device_id)',
'index reports.device_id'
);
} else {
self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD COLUMN device_id INTEGER NULL',
'alter reports.device_id'
);
}
}
}
private static function backfillCompanyMemberships(PDO $pdo): void {
if (!self::tableExists($pdo, 'users') || !self::tableExists($pdo, 'company_memberships')) {
return;
}
$companyId = 1;
$rows = $pdo->query('SELECT id, role FROM users')->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$uid = (int) $row['id'];
$globalRole = strtolower((string) ($row['role'] ?? 'viewer'));
$companyRole = in_array($globalRole, ['root', 'admin', 'platform_admin'], true) ? 'owner' : 'member';
if (self::isMysql()) {
$stmt = $pdo->prepare(
'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
);
} else {
$stmt = $pdo->prepare(
'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
);
}
$stmt->execute([$uid, $companyId, $companyRole]);
}
if (self::isMysql()) {
$pdo->exec(
"INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
);
} else {
$pdo->exec(
"INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
);
}
}
private static function ensureMysqlReportColumns(PDO $pdo): void {
$names = self::columnNames($pdo, 'reports');
if (!in_array('tags_json', $names, true)) {
if (self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD COLUMN tags_json LONGTEXT NOT NULL',
'alter reports.tags_json'
)) {
self::execSchemaSql(
$pdo,
"UPDATE reports SET tags_json = '[]' WHERE tags_json IS NULL OR tags_json = ''",
'backfill reports.tags_json'
);
}
}
}
private static function ensureMysqlReportViewsTable(PDO $pdo): void {
if (self::tableExists($pdo, 'report_views')) {
return;
}
$pdo->exec(
'CREATE TABLE IF NOT EXISTS report_views (
user_id INT UNSIGNED NOT NULL,
report_id BIGINT UNSIGNED NOT NULL,
viewed_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_id, report_id),
CONSTRAINT fk_report_views_report
FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
private static function ensureReportColumns(PDO $pdo): void {
$names = self::columnNames($pdo, 'reports');
if (!in_array('tags_json', $names, true)) {
self::execSchemaSql(
$pdo,
"ALTER TABLE reports ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'",
'alter reports.tags_json'
);
}
}
/** Indexes for list API (fingerprint stats join + sort by received_at). */
private static function ensureReportListIndexes(PDO $pdo): void {
if (!self::tableExists($pdo, 'reports')) {
return;
}
if (self::isMysql()) {
$existing = [];
$stmt = $pdo->query("SHOW INDEX FROM reports");
if ($stmt !== false) {
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$existing[(string) ($row['Key_name'] ?? '')] = true;
}
}
$add = static function (string $name, string $sql) use ($pdo, $existing): void {
if (!isset($existing[$name])) {
self::execSchemaSql($pdo, $sql, 'index reports.' . $name);
}
};
$add('idx_reports_company_fingerprint', 'ALTER TABLE reports ADD KEY idx_reports_company_fingerprint (company_id, fingerprint)');
$add('idx_reports_company_received', 'ALTER TABLE reports ADD KEY idx_reports_company_received (company_id, received_at_ms)');
if (!isset($existing['idx_reports_fp_received'])) {
self::execSchemaSql(
$pdo,
'ALTER TABLE reports ADD KEY idx_reports_fp_received (fingerprint, received_at_ms)',
'index reports.fp_received'
);
}
return;
}
self::execSchemaSql(
$pdo,
'CREATE INDEX IF NOT EXISTS idx_reports_company_fingerprint ON reports(company_id, fingerprint)',
'index reports.company_fingerprint'
);
self::execSchemaSql(
$pdo,
'CREATE INDEX IF NOT EXISTS idx_reports_company_received ON reports(company_id, received_at_ms)',
'index reports.company_received'
);
self::execSchemaSql(
$pdo,
'CREATE INDEX IF NOT EXISTS idx_reports_fp_received ON reports(fingerprint, received_at_ms)',
'index reports.fp_received'
);
}
private static function ensureReportViewsTable(PDO $pdo): void {
if (self::tableExists($pdo, 'report_views')) {
return;
}
$pdo->exec(
'CREATE TABLE IF NOT EXISTS report_views (
user_id INTEGER NOT NULL,
report_id INTEGER NOT NULL,
viewed_at_ms INTEGER NOT NULL,
PRIMARY KEY (user_id, report_id)
)'
);
}
private static function mysqlQuoteIdentifier(string $name): string {
return '`' . str_replace('`', '``', $name) . '`';
}
public static function tableExists(PDO $pdo, string $table): bool {
if (self::isMysql()) {
// App DB users often lack information_schema visibility; SHOW TABLES uses normal grants.
$stmt = $pdo->query('SHOW TABLES LIKE ' . $pdo->quote($table));
if ($stmt !== false && $stmt->fetchColumn() !== false) {
return true;
}
try {
$pdo->query('SELECT 1 FROM ' . self::mysqlQuoteIdentifier($table) . ' LIMIT 0');
return true;
} catch (PDOException $e) {
$sqlState = $e->getCode();
$errno = (int) ($e->errorInfo[1] ?? 0);
if ($sqlState === '42S02' || $errno === 1146) {
return false;
}
throw $e;
}
}
$stmt = $pdo->prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1"
);
$stmt->execute([$table]);
return $stmt->fetchColumn() !== false;
}
/** Serialize cross-request DDL (MariaDB GET_LOCK). No-op on SQLite. */
public static function withMigrationLock(PDO $pdo, string $name, callable $fn): void {
if (!self::isMysql()) {
$fn();
return;
}
$lockName = 'ac_migrate_' . preg_replace('/[^a-z0-9_]/', '_', strtolower($name));
$stmt = $pdo->query('SELECT GET_LOCK(' . $pdo->quote($lockName) . ', 90)');
$got = $stmt !== false && (int) $stmt->fetchColumn() === 1;
if (!$got) {
return;
}
try {
$fn();
} finally {
$pdo->query('SELECT RELEASE_LOCK(' . $pdo->quote($lockName) . ')');
}
}
/** @return list<string> */
public static function columnNames(PDO $pdo, string $table): array {
if (self::isMysql()) {
$stmt = $pdo->query('SHOW COLUMNS FROM ' . self::mysqlQuoteIdentifier($table));
if ($stmt === false) {
return [];
}
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'Field');
}
return array_column(
$pdo->query('PRAGMA table_info(' . preg_replace('/[^a-z0-9_]/i', '', $table) . ')')->fetchAll(PDO::FETCH_ASSOC),
'name'
);
}
/** @return list<string> */
public static function listTables(PDO $pdo): array {
if (self::isMysql()) {
$stmt = $pdo->query('SHOW TABLES');
if ($stmt === false) {
return [];
}
return array_values(array_filter($stmt->fetchAll(PDO::FETCH_COLUMN), 'is_string'));
}
return $pdo->query(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
)->fetchAll(PDO::FETCH_COLUMN);
}
private static function reportsSchemaOk(PDO $pdo): bool {
if (!self::tableExists($pdo, 'reports')) {
return false;
}
$names = self::columnNames($pdo, 'reports');
foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) {
if (!in_array($col, $names, true)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/** Phase 1 device registry — auto-register on crash upload. */
final class DeviceRepository {
public static function externalKey(array $device): string {
$parts = array_filter([
(string) ($device['manufacturer'] ?? ''),
(string) ($device['brand'] ?? ''),
(string) ($device['model'] ?? ''),
(string) ($device['product'] ?? ''),
(string) ($device['device'] ?? ''),
(string) ($device['serial'] ?? ''),
(string) ($device['android_id'] ?? ''),
], static fn ($v) => $v !== '');
if ($parts === []) {
return 'unknown';
}
return substr(hash('sha256', implode('|', $parts)), 0, 32);
}
public static function upsertFromPayload(int $companyId, array $device, string $source = 'auto'): int {
$companyId = max(1, $companyId);
$externalId = self::externalKey($device);
$now = (int) round(microtime(true) * 1000);
$manufacturer = trim((string) ($device['manufacturer'] ?? ''));
$model = trim((string) ($device['model'] ?? ''));
$display = trim($manufacturer . ' ' . $model);
$meta = json_encode($device, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($meta === false) {
$meta = '{}';
}
$pdo = Database::pdo();
if (Database::isMysql()) {
$stmt = $pdo->prepare(
'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source,
registered_at_ms, last_seen_at_ms, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
manufacturer = VALUES(manufacturer),
model = VALUES(model),
last_seen_at_ms = VALUES(last_seen_at_ms),
meta_json = VALUES(meta_json)'
);
$stmt->execute([
$companyId,
$externalId,
$display !== '' ? $display : null,
$manufacturer !== '' ? $manufacturer : null,
$model !== '' ? $model : null,
$source,
$now,
$now,
$meta,
]);
$sel = $pdo->prepare(
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
);
$sel->execute([$companyId, $externalId]);
return (int) $sel->fetchColumn();
}
$stmt = $pdo->prepare(
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
);
$stmt->execute([$companyId, $externalId]);
$existing = $stmt->fetchColumn();
if ($existing !== false) {
$upd = $pdo->prepare(
'UPDATE devices SET display_name = ?, manufacturer = ?, model = ?,
last_seen_at_ms = ?, meta_json = ? WHERE id = ?'
);
$upd->execute([
$display !== '' ? $display : null,
$manufacturer !== '' ? $manufacturer : null,
$model !== '' ? $model : null,
$now,
$meta,
(int) $existing,
]);
return (int) $existing;
}
$ins = $pdo->prepare(
'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source,
registered_at_ms, last_seen_at_ms, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$ins->execute([
$companyId,
$externalId,
$display !== '' ? $display : null,
$manufacturer !== '' ? $manufacturer : null,
$model !== '' ? $model : null,
$source,
$now,
$now,
$meta,
]);
return (int) $pdo->lastInsertId();
}
}

View File

@@ -0,0 +1,600 @@
<?php
declare(strict_types=1);
final class GraphRepository {
private static bool $schemaEnsured = false;
public static function ensureSchema(): void {
if (self::$schemaEnsured) {
return;
}
$pdo = Database::pdo();
Database::withMigrationLock($pdo, 'graph_sessions', static function () use ($pdo): void {
if (!Database::tableExists($pdo, 'graph_sessions')) {
self::createTable($pdo);
self::ensureIndexes($pdo);
return;
}
self::ensureColumns($pdo);
self::ensureIndexes($pdo);
});
self::$schemaEnsured = true;
}
private static function columnExists(PDO $pdo, string $table, string $column): bool {
if (Database::isMysql()) {
$stmt = $pdo->prepare(
'SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1'
);
$stmt->execute([$table, $column]);
return (bool) $stmt->fetchColumn();
}
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
if ($stmt === false) {
return false;
}
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (($row['name'] ?? '') === $column) {
return true;
}
}
return false;
}
private static function indexExists(PDO $pdo, string $table, string $index): bool {
if (Database::isMysql()) {
$stmt = $pdo->prepare(
'SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1'
);
$stmt->execute([$table, $index]);
return (bool) $stmt->fetchColumn();
}
$stmt = $pdo->query('PRAGMA index_list(' . $table . ')');
if ($stmt === false) {
return false;
}
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (($row['name'] ?? '') === $index) {
return true;
}
}
return false;
}
private static function createTable(PDO $pdo): void {
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS graph_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
user_id INT UNSIGNED NULL,
device_id VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
sdk_int INT NULL,
started_at_ms BIGINT NOT NULL,
ended_at_ms BIGINT NOT NULL,
duration_s INT NOT NULL,
completed TINYINT(1) NOT NULL DEFAULT 1,
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
avg_kbps INT NOT NULL DEFAULT 0,
recv_kbps INT NOT NULL DEFAULT 0,
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
ntp_correction_s INT NULL,
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
meta_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_graph_session_id (session_id),
KEY idx_graph_company_time (company_id, started_at_ms),
KEY idx_graph_device (device_id),
KEY idx_graph_app_version (app_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE IF NOT EXISTS graph_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL DEFAULT 1,
user_id INTEGER NULL,
device_id TEXT NULL,
app_version TEXT NULL,
sdk_int INTEGER NULL,
started_at_ms INTEGER NOT NULL,
ended_at_ms INTEGER NOT NULL,
duration_s INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 1,
direction TEXT NOT NULL DEFAULT 'sender',
transport TEXT NOT NULL DEFAULT 'wifi',
avg_kbps INTEGER NOT NULL DEFAULT 0,
recv_kbps INTEGER NOT NULL DEFAULT 0,
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
ntp_source TEXT NOT NULL DEFAULT 'device',
ntp_correction_s INTEGER NULL,
install_source TEXT NOT NULL DEFAULT 'direct',
meta_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
private static function ensureIndexes(PDO $pdo): void {
$defs = Database::isMysql()
? [
'idx_graph_user_time' => 'CREATE INDEX idx_graph_user_time ON graph_sessions (user_id, started_at_ms)',
'idx_graph_started' => 'CREATE INDEX idx_graph_started ON graph_sessions (started_at_ms)',
]
: [
'idx_graph_company_time' => 'CREATE INDEX IF NOT EXISTS idx_graph_company_time ON graph_sessions (company_id, started_at_ms)',
'idx_graph_user_time' => 'CREATE INDEX IF NOT EXISTS idx_graph_user_time ON graph_sessions (user_id, started_at_ms)',
'idx_graph_started' => 'CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms)',
'idx_graph_device' => 'CREATE INDEX IF NOT EXISTS idx_graph_device ON graph_sessions (device_id)',
];
foreach ($defs as $name => $sql) {
if (self::indexExists($pdo, 'graph_sessions', $name)) {
continue;
}
try {
$pdo->exec($sql);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
}
}
private static function ensureColumns(PDO $pdo): void {
$cols = [
'device_id' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL',
'app_version' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL',
'sdk_int' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
'completed' => Database::isMysql() ? 'TINYINT(1) NOT NULL DEFAULT 1' : 'INTEGER NOT NULL DEFAULT 1',
'recv_kbps' => Database::isMysql() ? 'INT NOT NULL DEFAULT 0' : 'INTEGER NOT NULL DEFAULT 0',
'ntp_correction_s' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL',
];
foreach ($cols as $name => $ddl) {
if (self::columnExists($pdo, 'graph_sessions', $name)) {
continue;
}
try {
$pdo->exec('ALTER TABLE graph_sessions ADD COLUMN ' . $name . ' ' . $ddl);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
}
}
public static function insertSession(array $in): void {
self::ensureSchema();
$pdo = Database::pdo();
$sessionId = trim((string) ($in['session_id'] ?? ''));
if ($sessionId === '') {
throw new InvalidArgumentException('session_id is required');
}
$start = (int) ($in['started_at_epoch_ms'] ?? 0);
$end = (int) ($in['ended_at_epoch_ms'] ?? 0);
if ($start <= 0 || $end <= 0 || $end < $start) {
throw new InvalidArgumentException('invalid session timing');
}
$dir = strtolower(trim((string) ($in['direction'] ?? 'sender')));
if ($dir !== 'sender' && $dir !== 'receiver') {
$dir = 'sender';
}
$transport = strtolower(trim((string) ($in['transport'] ?? 'wifi')));
if ($transport === '') {
$transport = 'wifi';
}
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? ($in['time_source'] ?? 'device'))));
if ($ntp === '') {
$ntp = 'device';
}
$install = strtolower(trim((string) ($in['install_source'] ?? 'direct')));
if ($install === '') {
$install = 'direct';
}
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
$recvKbps = max(0, (int) ($in['recv_kbps'] ?? 0));
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
if ($companyId <= 0) {
$companyId = Rbac::defaultCompanyId();
}
$userId = isset($in['user_id']) ? (int) $in['user_id'] : null;
if ($userId !== null && $userId <= 0) {
$userId = null;
}
$deviceId = trim((string) ($in['device_id'] ?? ''));
if ($deviceId === '' && is_array($in['device'] ?? null)) {
$deviceId = trim((string) (($in['device']['id'] ?? $in['device']['device_id'] ?? '')));
}
$appVersion = trim((string) ($in['app_version'] ?? ''));
if ($appVersion === '' && is_array($in['app'] ?? null)) {
$appVersion = trim((string) ($in['app']['version_name'] ?? $in['app']['version'] ?? ''));
}
$sdkInt = isset($in['sdk_int']) ? (int) $in['sdk_int'] : null;
if ($sdkInt === null && is_array($in['device'] ?? null)) {
$sdkInt = isset($in['device']['sdk_int']) ? (int) $in['device']['sdk_int'] : null;
}
$completed = 1;
if (array_key_exists('completed', $in)) {
$completed = !empty($in['completed']) ? 1 : 0;
}
$ntpCorrection = isset($in['ntp_correction_s']) ? (int) $in['ntp_correction_s'] : null;
if ($ntpCorrection === null && isset($in['ntp_correction_seconds'])) {
$ntpCorrection = (int) $in['ntp_correction_seconds'];
}
$meta = $in['meta'] ?? null;
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
if (Database::isMysql()) {
$sql = 'INSERT INTO graph_sessions
(session_id, company_id, user_id, device_id, app_version, sdk_int,
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
ended_at_ms = VALUES(ended_at_ms),
duration_s = VALUES(duration_s),
completed = VALUES(completed),
avg_kbps = VALUES(avg_kbps),
recv_kbps = VALUES(recv_kbps),
packet_loss_pct = VALUES(packet_loss_pct),
ntp_source = VALUES(ntp_source),
ntp_correction_s = VALUES(ntp_correction_s),
install_source = VALUES(install_source),
device_id = COALESCE(VALUES(device_id), device_id),
app_version = COALESCE(VALUES(app_version), app_version),
sdk_int = COALESCE(VALUES(sdk_int), sdk_int),
meta_json = VALUES(meta_json)';
} else {
$sql = 'INSERT OR REPLACE INTO graph_sessions
(session_id, company_id, user_id, device_id, app_version, sdk_int,
started_at_ms, ended_at_ms, duration_s, completed, direction, transport,
avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([
$sessionId,
$companyId,
$userId,
$deviceId !== '' ? $deviceId : null,
$appVersion !== '' ? $appVersion : null,
$sdkInt,
$start,
$end,
$duration,
$completed,
$dir,
$transport,
$avgKbps,
$recvKbps,
$loss,
$ntp,
$ntpCorrection,
$install,
$metaJson,
]);
}
public static function dashboardData(int $days = 14): array {
self::ensureSchema();
$days = max(1, min(60, $days));
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$scopeUserId = $uid > 0 ? $uid : null;
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
$viewer = self::viewerRole($user);
$payload = [
'window_days' => $days,
'viewer' => $viewer,
'links' => self::serviceLinks(),
];
$payload['live_cast'] = LiveCastRepository::analytics($days, $user ?? []);
$payload['user'] = self::buildScope($days, $activeCompanyId, null);
if ($viewer === 'user') {
return $payload;
}
$payload['slug_admin'] = self::buildScope($days, $activeCompanyId, null);
if ($viewer !== 'platform_admin') {
return $payload;
}
$payload['platform_admin'] = self::buildScope($days, null, null, true);
return $payload;
}
private static function viewerRole(?array $user): string {
if (Rbac::isGlobalAdmin($user)) {
return 'platform_admin';
}
$companyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
$uid = (int) ($user['id'] ?? 0);
$role = ($companyId > 0 && $uid > 0) ? Rbac::companyRole($uid, $companyId) : null;
if ($role === 'owner' || $role === 'admin') {
return 'slug_admin';
}
return 'user';
}
/** @return array<string, string> */
private static function serviceLinks(): array {
$prefix = '/app/androidcast_project';
return [
'hub' => $prefix . '/',
'graphs' => $prefix . '/graphs/',
'issues' => $prefix . '/issues/?view=reports',
'tickets' => $prefix . '/issues/?view=tickets',
'build' => $prefix . '/build/',
'git' => $prefix . '/git/',
];
}
private static function buildScope(int $days, ?int $companyId, ?int $userId, bool $platform = false): array {
$startMs = (int) ((time() - ($days * 86400)) * 1000);
$scope = [
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
'unique_devices_per_day' => self::uniqueDevicesPerDay($startMs, $companyId, $userId),
'avg_duration_s_per_day' => self::avgDurationPerDay($startMs, $companyId, $userId),
'send_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'sender'),
'recv_sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId, 'receiver'),
'success_ratio_pct' => self::successRatio($startMs, $companyId, $userId),
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'avg_kbps'),
'avg_recv_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId, 'recv_kbps'),
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
'app_versions' => self::breakdown('app_version', $startMs, $companyId, $userId),
];
if (Database::tableExists(Database::pdo(), 'tickets')) {
$scope['tickets_per_day'] = self::ticketsPerDay($startMs, $companyId);
}
if ($platform) {
$scope['top_crash_fingerprints'] = self::topCrashFingerprints($startMs, 8);
$scope['crashes_by_app_version'] = self::crashesByAppVersion($startMs);
$scope['ntp_correction_avg_s'] = self::avgNtpCorrection($startMs, $companyId, $userId);
}
return $scope;
}
private static function sessionsPerDay(
int $startMs,
?int $companyId,
?int $userId,
?string $direction = null
): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
if ($direction !== null) {
$where[] = 'direction = ?';
$params[] = $direction;
}
self::appendScope($where, $params, $companyId, $userId);
return self::countByDay('graph_sessions', 'started_at_ms', $where, $params);
}
private static function uniqueDevicesPerDay(int $startMs, ?int $companyId, ?int $userId): array {
$where = ['started_at_ms >= ?', "device_id IS NOT NULL", "device_id <> ''"];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$day = self::msDayExpr('started_at_ms');
$sql = 'SELECT ' . $day . ' AS d, COUNT(DISTINCT device_id) AS c FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY d ORDER BY d';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$bucket = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bucket[(string) ($row['d'] ?? '')] = (int) ($row['c'] ?? 0);
}
return self::assocToSeries($bucket);
}
private static function avgDurationPerDay(int $startMs, ?int $companyId, ?int $userId): array {
return self::avgNumericPerDay($startMs, $companyId, $userId, 'duration_s');
}
private static function successRatio(int $startMs, ?int $companyId, ?int $userId): float {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT COUNT(*) AS total,
SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) AS ok
FROM graph_sessions WHERE ' . implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$total = (int) ($row['total'] ?? 0);
$ok = (int) ($row['ok'] ?? 0);
if ($total <= 0) {
return 0.0;
}
return round(100.0 * $ok / $total, 1);
}
private static function avgBitratePerDay(
int $startMs,
?int $companyId,
?int $userId,
string $column
): array {
$allowed = ['avg_kbps', 'recv_kbps'];
if (!in_array($column, $allowed, true)) {
return [];
}
return self::avgNumericPerDay($startMs, $companyId, $userId, $column);
}
private static function crashesPerDay(int $startMs, ?int $companyId): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$where = ['received_at_ms >= ?'];
$params = [$startMs];
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
return self::countByDay('reports', 'received_at_ms', $where, $params);
}
private static function ticketsPerDay(int $startMs, ?int $companyId): array {
$where = ['opened_at_ms >= ?'];
$params = [$startMs];
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
return self::countByDay('tickets', 'opened_at_ms', $where, $params);
}
private static function topCrashFingerprints(int $startMs, int $limit): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$sql = 'SELECT fingerprint, COUNT(*) AS c, MAX(id) AS last_id
FROM reports WHERE received_at_ms >= ?
GROUP BY fingerprint ORDER BY c DESC LIMIT ' . (int) $limit;
$stmt = Database::pdo()->prepare($sql);
$stmt->execute([$startMs]);
$base = Auth::basePath();
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$fp = (string) ($row['fingerprint'] ?? '');
$id = (int) ($row['last_id'] ?? 0);
$out[] = [
'label' => $fp,
'value' => (int) ($row['c'] ?? 0),
'href' => $id > 0 ? $base . '/?view=report&id=' . $id : null,
];
}
return $out;
}
private static function crashesByAppVersion(int $startMs): array {
if (!Database::tableExists(Database::pdo(), 'reports')) {
return [];
}
$sql = "SELECT COALESCE(NULLIF(app_version, ''), 'unknown') AS k, COUNT(*) AS c
FROM reports WHERE received_at_ms >= ?
GROUP BY k ORDER BY c DESC LIMIT 12";
$stmt = Database::pdo()->prepare($sql);
$stmt->execute([$startMs]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
private static function avgNtpCorrection(int $startMs, ?int $companyId, ?int $userId): ?float {
$where = ['started_at_ms >= ?', 'ntp_correction_s IS NOT NULL'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT AVG(ABS(ntp_correction_s)) AS avg_s FROM graph_sessions WHERE '
. implode(' AND ', $where);
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$v = $stmt->fetchColumn();
return $v === false || $v === null ? null : round((float) $v, 2);
}
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
$allowed = ['ntp_source', 'install_source', 'transport', 'app_version'];
if (!in_array($column, $allowed, true)) {
return [];
}
$where = ['started_at_ms >= ?'];
$params = [$startMs];
if ($column === 'app_version') {
$where[] = "app_version IS NOT NULL";
$where[] = "app_version <> ''";
}
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$label = (string) ($row['k'] ?? 'unknown');
if ($label === '') {
$label = 'unknown';
}
$out[] = ['label' => $label, 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
private static function msDayExpr(string $msColumn): string {
if (Database::isMysql()) {
return 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))';
}
return "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')";
}
private static function countByDay(string $table, string $msColumn, array $where, array $params): array {
$day = self::msDayExpr($msColumn);
$sql = 'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . ' WHERE '
. implode(' AND ', $where) . ' GROUP BY d ORDER BY d';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$bucket = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bucket[(string) ($row['d'] ?? '')] = (int) ($row['c'] ?? 0);
}
return self::assocToSeries($bucket);
}
private static function avgNumericPerDay(
int $startMs,
?int $companyId,
?int $userId,
string $column
): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$day = self::msDayExpr('started_at_ms');
$sql = 'SELECT ' . $day . ' AS d, AVG(' . $column . ') AS v FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY d ORDER BY d';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$avg = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$avg[(string) ($row['d'] ?? '')] = round((float) ($row['v'] ?? 0), 1);
}
return self::assocToSeries($avg);
}
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
if ($userId !== null && $userId > 0) {
$where[] = 'user_id = ?';
$params[] = $userId;
}
}
private static function assocToSeries(array $assoc): array {
ksort($assoc);
$out = [];
foreach ($assoc as $k => $v) {
$out[] = ['x' => $k, 'y' => $v];
}
return $out;
}
}

View File

@@ -0,0 +1,607 @@
<?php
declare(strict_types=1);
final class LiveCastRepository {
private static bool $schemaEnsured = false;
public static function ensureSchema(): void {
if (self::$schemaEnsured) {
return;
}
$pdo = Database::pdo();
Database::withMigrationLock($pdo, 'live_cast_sessions', static function () use ($pdo): void {
self::createTables($pdo);
self::ensureIndexes($pdo);
});
self::$schemaEnsured = true;
}
private static function createTables(PDO $pdo): void {
if (!Database::tableExists($pdo, 'live_cast_sessions')) {
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE live_cast_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
owner_user_id INT UNSIGNED NULL,
owner_username VARCHAR(64) NULL,
device_id VARCHAR(128) NULL,
platform VARCHAR(24) NOT NULL DEFAULT 'android',
status ENUM('intent','live','closed','expired') NOT NULL DEFAULT 'intent',
started_at_ms BIGINT NOT NULL,
last_heartbeat_ms BIGINT NOT NULL,
ended_at_ms BIGINT NULL,
max_duration_s INT NOT NULL DEFAULT 300,
join_short_url TEXT NULL,
join_qr_url TEXT NULL,
join_slug VARCHAR(32) NULL,
direct_short_url TEXT NULL,
direct_qr_url TEXT NULL,
direct_slug VARCHAR(32) NULL,
codec_video VARCHAR(32) NULL,
codec_audio VARCHAR(32) NULL,
bw_mode VARCHAR(24) NULL,
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_live_cast_session_id (session_id),
KEY idx_live_cast_company_status (company_id, status),
KEY idx_live_cast_last_hb (last_heartbeat_ms),
KEY idx_live_cast_owner (owner_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
} else {
$pdo->exec(
"CREATE TABLE live_cast_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL DEFAULT 1,
owner_user_id INTEGER NULL,
owner_username TEXT NULL,
device_id TEXT NULL,
platform TEXT NOT NULL DEFAULT 'android',
status TEXT NOT NULL DEFAULT 'intent',
started_at_ms INTEGER NOT NULL,
last_heartbeat_ms INTEGER NOT NULL,
ended_at_ms INTEGER NULL,
max_duration_s INTEGER NOT NULL DEFAULT 300,
join_short_url TEXT NULL,
join_qr_url TEXT NULL,
join_slug TEXT NULL,
direct_short_url TEXT NULL,
direct_qr_url TEXT NULL,
direct_slug TEXT NULL,
codec_video TEXT NULL,
codec_audio TEXT NULL,
bw_mode TEXT NULL,
metadata_json TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
}
if (!Database::tableExists($pdo, 'live_cast_join_events')) {
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE live_cast_join_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
event_type ENUM('open','join','leave') NOT NULL DEFAULT 'open',
platform VARCHAR(24) NOT NULL DEFAULT 'unknown',
channel VARCHAR(24) NOT NULL DEFAULT 'short_link',
direct TINYINT(1) NOT NULL DEFAULT 0,
user_id INT UNSIGNED NULL,
anon_id VARCHAR(96) NULL,
user_agent VARCHAR(255) NULL,
ip_hash CHAR(64) NULL,
created_at_ms BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_live_join_session_time (session_id, created_at_ms),
KEY idx_live_join_company_time (company_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
} else {
$pdo->exec(
"CREATE TABLE live_cast_join_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
company_id INTEGER NOT NULL DEFAULT 1,
event_type TEXT NOT NULL DEFAULT 'open',
platform TEXT NOT NULL DEFAULT 'unknown',
channel TEXT NOT NULL DEFAULT 'short_link',
direct INTEGER NOT NULL DEFAULT 0,
user_id INTEGER NULL,
anon_id TEXT NULL,
user_agent TEXT NULL,
ip_hash TEXT NULL,
created_at_ms INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
}
}
private static function ensureIndexes(PDO $pdo): void {
if (Database::isMysql()) {
return;
}
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_company_status ON live_cast_sessions (company_id, status)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_last_hb ON live_cast_sessions (last_heartbeat_ms)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_session_time ON live_cast_join_events (session_id, created_at_ms)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_company_time ON live_cast_join_events (company_id, created_at_ms)');
}
/** @return array<string,mixed> */
public static function createIntent(array $input, array $actor): array {
self::ensureSchema();
self::expireStale();
$pdo = Database::pdo();
$now = (int) floor(microtime(true) * 1000);
$userId = (int) ($actor['id'] ?? 0);
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
$ownerUsername = trim((string) ($actor['username'] ?? ''));
$platform = strtolower(trim((string) ($input['platform'] ?? 'android')));
if ($platform === '') {
$platform = 'android';
}
$deviceId = trim((string) ($input['device_id'] ?? ''));
$maxDuration = (int) ($input['max_duration_s'] ?? 300);
$maxDuration = max(60, min(1800, $maxDuration));
if (str_starts_with($platform, 'education')) {
$maxDuration = min($maxDuration, 300);
}
$sessionId = self::newSessionId($now, $companyId, $userId);
$joinLongUrl = self::joinLongUrl($sessionId, false);
$directLongUrl = self::joinLongUrl($sessionId, true);
$links = self::makeShortLinks($joinLongUrl, $directLongUrl);
$meta = is_array($input['meta'] ?? null) ? $input['meta'] : [];
$stmt = $pdo->prepare(
'INSERT INTO live_cast_sessions (
session_id, company_id, owner_user_id, owner_username, device_id, platform, status,
started_at_ms, last_heartbeat_ms, max_duration_s,
join_short_url, join_qr_url, join_slug, direct_short_url, direct_qr_url, direct_slug,
codec_video, codec_audio, bw_mode, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$sessionId,
$companyId,
$userId > 0 ? $userId : null,
$ownerUsername !== '' ? $ownerUsername : null,
$deviceId !== '' ? $deviceId : null,
$platform,
'intent',
$now,
$now,
$maxDuration,
$links['join_short_url'],
$links['join_qr_url'],
$links['join_slug'],
$links['direct_short_url'],
$links['direct_qr_url'],
$links['direct_slug'],
trim((string) ($input['codec_video'] ?? '')) ?: null,
trim((string) ($input['codec_audio'] ?? '')) ?: null,
trim((string) ($input['bw_mode'] ?? '')) ?: null,
safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
]);
return self::sessionById($sessionId) ?? [];
}
/** @return array<string,mixed>|null */
public static function heartbeat(string $sessionId, array $input, array $actor): ?array {
self::ensureSchema();
self::expireStale();
$sessionId = trim($sessionId);
if ($sessionId === '') {
return null;
}
$pdo = Database::pdo();
$now = (int) floor(microtime(true) * 1000);
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
$status = strtolower(trim((string) ($input['status'] ?? 'active')));
if ($status !== 'ended' && $status !== 'active') {
$status = 'active';
}
$nextState = $status === 'ended' ? 'closed' : 'live';
$stmt = $pdo->prepare(
'UPDATE live_cast_sessions
SET status = ?, last_heartbeat_ms = ?, ended_at_ms = CASE WHEN ? = ? THEN ? ELSE ended_at_ms END,
codec_video = COALESCE(NULLIF(?, \'\'), codec_video),
codec_audio = COALESCE(NULLIF(?, \'\'), codec_audio),
bw_mode = COALESCE(NULLIF(?, \'\'), bw_mode)
WHERE session_id = ? AND company_id = ?'
);
$stmt->execute([
$nextState,
$now,
$nextState,
'closed',
$now,
trim((string) ($input['codec_video'] ?? '')),
trim((string) ($input['codec_audio'] ?? '')),
trim((string) ($input['bw_mode'] ?? '')),
$sessionId,
$companyId,
]);
if ($stmt->rowCount() < 1) {
return null;
}
if (isset($input['stats']) && is_array($input['stats'])) {
$row = self::sessionById($sessionId);
$meta = [];
if ($row !== null && !empty($row['metadata_json'])) {
$decoded = json_decode((string) $row['metadata_json'], true);
if (is_array($decoded)) {
$meta = $decoded;
}
}
$meta['last_stats'] = $input['stats'];
$meta['last_stats_ms'] = $now;
$upd = $pdo->prepare('UPDATE live_cast_sessions SET metadata_json = ? WHERE session_id = ?');
$upd->execute([
safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$sessionId,
]);
}
return self::sessionById($sessionId);
}
/** @return list<array<string,mixed>> */
public static function activeSessions(array $actor, ?string $platform = null): array {
self::ensureSchema();
self::expireStale();
$pdo = Database::pdo();
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
$where = ['company_id = ?', "status IN ('intent','live')"];
$params = [$companyId];
if ($platform !== null && trim($platform) !== '') {
$where[] = 'platform = ?';
$params[] = strtolower(trim($platform));
}
$sql = 'SELECT session_id, owner_user_id, owner_username, device_id, platform, status,
started_at_ms, last_heartbeat_ms, max_duration_s, ended_at_ms,
join_short_url, join_qr_url, direct_short_url, direct_qr_url,
codec_video, codec_audio, bw_mode
FROM live_cast_sessions
WHERE ' . implode(' AND ', $where) . '
ORDER BY last_heartbeat_ms DESC LIMIT 200';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = self::normalizeSessionRow($row);
}
return $out;
}
/** @return array<string,mixed> */
public static function analytics(int $days, array $actor): array {
self::ensureSchema();
self::expireStale();
$days = max(1, min(90, $days));
$startMs = (int) ((time() - ($days * 86400)) * 1000);
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
$pdo = Database::pdo();
$castsPerDay = self::countByDay($pdo, 'live_cast_sessions', 'started_at_ms', $startMs, $companyId);
$avgDurationPerDay = self::avgDurationByDay($pdo, $startMs, $companyId);
$platformMix = self::breakdown($pdo, 'live_cast_sessions', 'platform', $startMs, $companyId);
$videoCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_video', $startMs, $companyId);
$audioCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_audio', $startMs, $companyId);
$bwModeMix = self::breakdown($pdo, 'live_cast_sessions', 'bw_mode', $startMs, $companyId);
$joinsPerPlatform = self::breakdown($pdo, 'live_cast_join_events', 'platform', $startMs, $companyId);
$topUsersStmt = $pdo->prepare(
'SELECT COALESCE(NULLIF(owner_username, \'\'), \'unknown\') AS u, COUNT(*) AS c
FROM live_cast_sessions
WHERE company_id = ? AND started_at_ms >= ?
GROUP BY u ORDER BY c DESC LIMIT 20'
);
$topUsersStmt->execute([$companyId, $startMs]);
$topUsers = [];
foreach ($topUsersStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$topUsers[] = ['label' => (string) ($row['u'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
}
return [
'window_days' => $days,
'casts_per_day' => $castsPerDay,
'avg_length_s_per_day' => $avgDurationPerDay,
'platform_mix' => $platformMix,
'joins_platform_mix' => $joinsPerPlatform,
'video_codec_mix' => $videoCodecMix,
'audio_codec_mix' => $audioCodecMix,
'bw_mode_mix' => $bwModeMix,
'top_users' => $topUsers,
];
}
public static function registerJoin(string $sessionId, array $payload, array $actor): bool {
self::ensureSchema();
$sessionId = trim($sessionId);
if ($sessionId === '') {
return false;
}
$session = self::sessionById($sessionId);
if ($session === null) {
return false;
}
$pdo = Database::pdo();
$now = (int) floor(microtime(true) * 1000);
$companyId = (int) ($session['company_id'] ?? Rbac::defaultCompanyId());
$eventType = strtolower(trim((string) ($payload['event_type'] ?? 'open')));
if (!in_array($eventType, ['open', 'join', 'leave'], true)) {
$eventType = 'open';
}
$platform = strtolower(trim((string) ($payload['platform'] ?? 'unknown')));
if ($platform === '') {
$platform = 'unknown';
}
$channel = strtolower(trim((string) ($payload['channel'] ?? 'short_link')));
if ($channel === '') {
$channel = 'short_link';
}
$direct = !empty($payload['direct']) ? 1 : 0;
$userId = (int) ($actor['id'] ?? 0);
$anon = trim((string) ($payload['anon_id'] ?? ''));
$ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$ip = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
$ipHash = $ip !== '' ? hash('sha256', $ip . '|ac-live-cast') : null;
$stmt = $pdo->prepare(
'INSERT INTO live_cast_join_events
(session_id, company_id, event_type, platform, channel, direct, user_id, anon_id, user_agent, ip_hash, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$sessionId,
$companyId,
$eventType,
$platform,
$channel,
$direct,
$userId > 0 ? $userId : null,
$anon !== '' ? $anon : null,
$ua !== '' ? substr($ua, 0, 255) : null,
$ipHash,
$now,
]);
return true;
}
/** @return array<string,mixed>|null */
public static function sessionById(string $sessionId): ?array {
self::ensureSchema();
$stmt = Database::pdo()->prepare(
'SELECT * FROM live_cast_sessions WHERE session_id = ? LIMIT 1'
);
$stmt->execute([$sessionId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return null;
}
return self::normalizeSessionRow($row);
}
private static function expireStale(): void {
$pdo = Database::pdo();
$now = (int) floor(microtime(true) * 1000);
$staleBefore = $now - 60_000;
$sql = "UPDATE live_cast_sessions
SET status = 'expired', ended_at_ms = COALESCE(ended_at_ms, ?)
WHERE status IN ('intent','live')
AND last_heartbeat_ms < ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$now, $staleBefore]);
}
/** @return array<string,mixed> */
private static function normalizeSessionRow(array $row): array {
return [
'session_id' => (string) ($row['session_id'] ?? ''),
'company_id' => (int) ($row['company_id'] ?? 0),
'owner_user_id' => isset($row['owner_user_id']) ? (int) $row['owner_user_id'] : null,
'owner_username' => (string) ($row['owner_username'] ?? ''),
'device_id' => (string) ($row['device_id'] ?? ''),
'platform' => (string) ($row['platform'] ?? ''),
'status' => (string) ($row['status'] ?? ''),
'started_at_ms' => (int) ($row['started_at_ms'] ?? 0),
'last_heartbeat_ms' => (int) ($row['last_heartbeat_ms'] ?? 0),
'ended_at_ms' => isset($row['ended_at_ms']) ? (int) $row['ended_at_ms'] : null,
'max_duration_s' => (int) ($row['max_duration_s'] ?? 0),
'join_short_url' => (string) ($row['join_short_url'] ?? ''),
'join_qr_url' => (string) ($row['join_qr_url'] ?? ''),
'direct_short_url' => (string) ($row['direct_short_url'] ?? ''),
'direct_qr_url' => (string) ($row['direct_qr_url'] ?? ''),
'codec_video' => (string) ($row['codec_video'] ?? ''),
'codec_audio' => (string) ($row['codec_audio'] ?? ''),
'bw_mode' => (string) ($row['bw_mode'] ?? ''),
'metadata_json' => (string) ($row['metadata_json'] ?? ''),
];
}
private static function newSessionId(int $nowMs, int $companyId, int $userId): string {
$seed = $nowMs . ':' . $companyId . ':' . $userId . ':' . bin2hex(random_bytes(6));
return 'live-' . substr(hash('sha256', $seed), 0, 20);
}
private static function joinLongUrl(string $sessionId, bool $direct): string {
$base = rtrim((string) cfg('base_path', '/app/androidcast_project/issues'), '/');
if (str_ends_with($base, '/crashes')) {
$base = substr($base, 0, -strlen('/crashes')) . '/issues';
}
$qs = 'session_id=' . rawurlencode($sessionId);
if ($direct) {
$qs .= '&direct=1';
}
return 'https://apps.f0xx.org' . $base . '/live/join?' . $qs;
}
/** @return array<string, string|null> */
private static function makeShortLinks(string $joinLongUrl, string $directLongUrl): array {
$out = [
'join_short_url' => null,
'join_qr_url' => null,
'join_slug' => null,
'direct_short_url' => null,
'direct_qr_url' => null,
'direct_slug' => null,
];
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
return $out;
}
$bearers = ShortLinksRepository::listBearers();
if ($bearers === []) {
return $out;
}
$bearerId = (int) ($bearers[0]['id'] ?? 0);
if ($bearerId <= 0) {
return $out;
}
$join = ShortLinksRepository::createLink($bearerId, $joinLongUrl, 3600);
if (!empty($join['ok'])) {
$out['join_short_url'] = (string) ($join['short_url'] ?? '');
$out['join_qr_url'] = (string) ($join['qr_url'] ?? '');
$out['join_slug'] = (string) ($join['slug'] ?? '');
}
$direct = ShortLinksRepository::createLink($bearerId, $directLongUrl, 3600);
if (!empty($direct['ok'])) {
$out['direct_short_url'] = (string) ($direct['short_url'] ?? '');
$out['direct_qr_url'] = (string) ($direct['qr_url'] ?? '');
$out['direct_slug'] = (string) ($direct['slug'] ?? '');
}
return $out;
}
/** @return list<array{x:string,y:int}> */
private static function countByDay(PDO $pdo, string $table, string $msColumn, int $startMs, int $companyId): array {
$day = Database::isMysql()
? 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))'
: "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')";
$stmt = $pdo->prepare(
'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . '
WHERE company_id = ? AND ' . $msColumn . ' >= ?
GROUP BY d ORDER BY d'
);
$stmt->execute([$companyId, $startMs]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => (int) ($row['c'] ?? 0)];
}
return $out;
}
/** @return list<array{x:string,y:float}> */
private static function avgDurationByDay(PDO $pdo, int $startMs, int $companyId): array {
$day = Database::isMysql()
? 'DATE(FROM_UNIXTIME(FLOOR(started_at_ms / 1000)))'
: "strftime('%Y-%m-%d', started_at_ms / 1000, 'unixepoch')";
$stmt = $pdo->prepare(
'SELECT ' . $day . ' AS d,
AVG(CASE WHEN ended_at_ms IS NOT NULL AND ended_at_ms >= started_at_ms
THEN (ended_at_ms - started_at_ms) / 1000.0 ELSE 0 END) AS v
FROM live_cast_sessions
WHERE company_id = ? AND started_at_ms >= ?
GROUP BY d ORDER BY d'
);
$stmt->execute([$companyId, $startMs]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => round((float) ($row['v'] ?? 0), 1)];
}
return $out;
}
/** @return list<array<string,mixed>> */
public static function listSessions(int $days, array $actor, int $limit = 200): array {
self::ensureSchema();
self::expireStale();
$days = max(1, min(90, $days));
$startMs = (int) ((time() - ($days * 86400)) * 1000);
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
$limit = max(1, min(500, $limit));
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT s.session_id, s.owner_user_id, s.owner_username, s.device_id, s.platform, s.status,
s.started_at_ms, s.last_heartbeat_ms, s.ended_at_ms, s.max_duration_s,
s.join_short_url, s.direct_short_url, s.codec_video, s.codec_audio, s.bw_mode,
(SELECT COUNT(*) FROM live_cast_join_events j
WHERE j.session_id = s.session_id AND j.event_type IN (\'open\', \'join\')) AS join_opens
FROM live_cast_sessions s
WHERE s.company_id = ? AND s.started_at_ms >= ?
ORDER BY s.last_heartbeat_ms DESC
LIMIT ' . (int) $limit
);
$stmt->execute([$companyId, $startMs]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$normalized = self::normalizeSessionRow($row);
$normalized['join_opens'] = (int) ($row['join_opens'] ?? 0);
$out[] = $normalized;
}
return $out;
}
/** @return array<string,mixed>|null */
public static function publicSession(string $sessionId): ?array {
$row = self::sessionById($sessionId);
if ($row === null) {
return null;
}
return [
'session_id' => $row['session_id'],
'owner_username' => $row['owner_username'],
'platform' => $row['platform'],
'status' => $row['status'],
'started_at_ms' => $row['started_at_ms'],
'last_heartbeat_ms' => $row['last_heartbeat_ms'],
'ended_at_ms' => $row['ended_at_ms'],
'max_duration_s' => $row['max_duration_s'],
'codec_video' => $row['codec_video'],
'codec_audio' => $row['codec_audio'],
'bw_mode' => $row['bw_mode'],
'join_short_url' => $row['join_short_url'] ?? '',
'join_qr_url' => $row['join_qr_url'] ?? '',
'signaling_api' => Auth::basePath() . '/api/live_cast_signaling.php',
];
}
/** @return list<array{label:string,value:int}> */
private static function breakdown(PDO $pdo, string $table, string $column, int $startMs, int $companyId): array {
$allowedTables = ['live_cast_sessions', 'live_cast_join_events'];
if (!in_array($table, $allowedTables, true)) {
return [];
}
$allowedCols = ['platform', 'codec_video', 'codec_audio', 'bw_mode'];
if ($table === 'live_cast_join_events') {
$allowedCols = ['platform'];
}
if (!in_array($column, $allowedCols, true)) {
return [];
}
$timeColumn = $table === 'live_cast_sessions' ? 'started_at_ms' : 'created_at_ms';
$stmt = $pdo->prepare(
'SELECT COALESCE(NULLIF(' . $column . ', \'\'), \'unknown\') AS k, COUNT(*) AS c
FROM ' . $table . '
WHERE company_id = ? AND ' . $timeColumn . ' >= ?
GROUP BY k ORDER BY c DESC'
);
$stmt->execute([$companyId, $startMs]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/** WebRTC signaling store (offer/answer/ICE) — REST polling, no MCU/SFU. */
final class LiveCastSignalingRepository {
private static bool $schemaEnsured = false;
public static function ensureSchema(): void {
if (self::$schemaEnsured) {
return;
}
$pdo = Database::pdo();
Database::withMigrationLock($pdo, 'live_cast_signal_messages', static function () use ($pdo): void {
if (!Database::tableExists($pdo, 'live_cast_signal_messages')) {
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE live_cast_signal_messages (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(96) NOT NULL,
peer_role VARCHAR(16) NOT NULL DEFAULT 'caster',
msg_type VARCHAR(24) NOT NULL,
payload_json LONGTEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
KEY idx_live_signal_session_id (session_id, id),
KEY idx_live_signal_session_type (session_id, msg_type, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
} else {
$pdo->exec(
"CREATE TABLE live_cast_signal_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
peer_role TEXT NOT NULL DEFAULT 'caster',
msg_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
)"
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_signal_session_id ON live_cast_signal_messages (session_id, id)');
}
}
});
self::$schemaEnsured = true;
}
/** @param array<string,mixed> $payload */
public static function post(string $sessionId, string $peerRole, string $msgType, array $payload): int {
self::ensureSchema();
$sessionId = trim($sessionId);
$peerRole = strtolower(trim($peerRole));
$msgType = strtolower(trim($msgType));
if ($sessionId === '' || $msgType === '') {
return 0;
}
if (!in_array($peerRole, ['caster', 'viewer', 'mobile'], true)) {
$peerRole = 'caster';
}
$now = (int) floor(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO live_cast_signal_messages (session_id, peer_role, msg_type, payload_json, created_at_ms)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([
$sessionId,
$peerRole,
$msgType,
safe_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$now,
]);
return (int) $pdo->lastInsertId();
}
/** @return list<array<string,mixed>> */
public static function poll(string $sessionId, int $sinceId, ?string $msgType = null, ?string $fromRole = null): array {
self::ensureSchema();
$sessionId = trim($sessionId);
if ($sessionId === '') {
return [];
}
$sinceId = max(0, $sinceId);
$where = ['session_id = ?', 'id > ?'];
$params = [$sessionId, $sinceId];
if ($msgType !== null && trim($msgType) !== '') {
$where[] = 'msg_type = ?';
$params[] = strtolower(trim($msgType));
}
if ($fromRole !== null && trim($fromRole) !== '') {
$where[] = 'peer_role = ?';
$params[] = strtolower(trim($fromRole));
}
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT id, session_id, peer_role, msg_type, payload_json, created_at_ms
FROM live_cast_signal_messages
WHERE ' . implode(' AND ', $where) . '
ORDER BY id ASC LIMIT 200'
);
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$payload = json_decode((string) ($row['payload_json'] ?? ''), true);
$out[] = [
'id' => (int) ($row['id'] ?? 0),
'session_id' => (string) ($row['session_id'] ?? ''),
'peer_role' => (string) ($row['peer_role'] ?? ''),
'msg_type' => (string) ($row['msg_type'] ?? ''),
'payload' => is_array($payload) ? $payload : [],
'created_at_ms' => (int) ($row['created_at_ms'] ?? 0),
];
}
return $out;
}
/** @return array<string,mixed>|null */
public static function latest(string $sessionId, string $msgType, ?string $fromRole = null): ?array {
self::ensureSchema();
$rows = self::poll($sessionId, 0, $msgType, $fromRole);
if ($rows === []) {
return null;
}
return $rows[count($rows) - 1];
}
public static function purgeSession(string $sessionId): void {
self::ensureSchema();
$sessionId = trim($sessionId);
if ($sessionId === '') {
return;
}
$stmt = Database::pdo()->prepare('DELETE FROM live_cast_signal_messages WHERE session_id = ?');
$stmt->execute([$sessionId]);
}
}

View File

@@ -0,0 +1,401 @@
<?php
declare(strict_types=1);
/**
* Phase 1 RBAC: global platform roles + company memberships.
* MariaDB-style: global scope (root/platform) vs company scope (owner/admin/member).
*/
final class Rbac {
/** Global roles stored on users.role */
public const GLOBAL_ROOT = 'root';
public const GLOBAL_PLATFORM_ADMIN = 'platform_admin';
/** Legacy alias — treated as platform_admin */
public const GLOBAL_ADMIN_LEGACY = 'admin';
public const GLOBAL_VIEWER = 'viewer';
public const COMPANY_OWNER = 'owner';
public const COMPANY_ADMIN = 'admin';
public const COMPANY_MEMBER = 'member';
/** @var list<string> */
private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY];
/** Company role => granted actions (unless global admin). */
/**
* Named privilege sets stored in company_memberships.permissions_json as {"grants":[...]}.
* Company role defaults still apply; grants extend (not replace) role actions.
*/
public const PRIVILEGE_SET_NONE = '';
public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user';
public const PRIVILEGE_SET_REMOTE_ACCESS_ADMIN = 'remote_access_admin';
public const PRIVILEGE_SET_SHORT_LINKS_VIEWER = 'short_links_viewer';
public const PRIVILEGE_SET_SHORT_LINKS_USER = 'short_links_user';
/** @var array<string, list<string>> */
public const PRIVILEGE_SETS = [
self::PRIVILEGE_SET_REMOTE_ACCESS_USER => [
'remote_access_view',
'remote_access_operate',
],
self::PRIVILEGE_SET_REMOTE_ACCESS_ADMIN => [
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
],
self::PRIVILEGE_SET_SHORT_LINKS_VIEWER => [
'short_links_view',
],
self::PRIVILEGE_SET_SHORT_LINKS_USER => [
'short_links_view',
'short_links_operate',
],
];
private const COMPANY_ROLE_ACTIONS = [
self::COMPANY_OWNER => [
'view_reports',
'edit_tags',
'upload_reports',
'manage_devices',
'manage_company_users',
'manage_company_settings',
'manage_self',
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
'short_links_view',
'short_links_operate',
],
self::COMPANY_ADMIN => [
'view_reports',
'edit_tags',
'upload_reports',
'manage_devices',
'manage_company_users',
'manage_self',
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
'short_links_view',
'short_links_operate',
],
self::COMPANY_MEMBER => [
'view_reports',
'manage_self',
'remote_access_view',
],
];
public static function normalizeGlobalRole(string $role): string {
$role = strtolower(trim($role));
if ($role === self::GLOBAL_ADMIN_LEGACY) {
return self::GLOBAL_PLATFORM_ADMIN;
}
return $role;
}
public static function isGlobalAdmin(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
$role = self::normalizeGlobalRole((string) ($user['role'] ?? ''));
return in_array($role, self::GLOBAL_ADMIN_ROLES, true);
}
/** @return list<int>|null null = all companies (global admin) */
public static function allowedCompanyIds(?array $user = null): ?array {
if (self::isGlobalAdmin($user)) {
return null;
}
$user ??= Auth::user();
if (!$user) {
return [];
}
$ids = [];
foreach ($user['companies'] ?? [] as $c) {
if (isset($c['id'])) {
$ids[] = (int) $c['id'];
}
}
return array_values(array_unique($ids));
}
public static function activeCompanyId(?array $user = null): ?int {
$user ??= Auth::user();
if (!$user) {
return null;
}
$id = (int) ($user['active_company_id'] ?? 0);
return $id > 0 ? $id : null;
}
public static function companyRole(int $userId, int $companyId): ?string {
$stmt = Database::pdo()->prepare(
'SELECT role FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
);
$stmt->execute([$userId, $companyId]);
$role = $stmt->fetchColumn();
return is_string($role) && $role !== '' ? $role : null;
}
public static function can(string $action, ?int $companyId = null, ?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
if (self::isGlobalAdmin($user)) {
return true;
}
if ($action === 'manage_platform') {
return false;
}
if ($action === 'manage_self') {
return true;
}
$companyId ??= self::activeCompanyId($user);
if ($companyId === null || $companyId <= 0) {
return false;
}
$role = self::membershipRoleForUser($user, $companyId);
if ($role === null) {
return false;
}
$grants = self::COMPANY_ROLE_ACTIONS[$role] ?? [];
if (in_array($action, $grants, true)) {
return true;
}
return self::hasPermissionOverride($user, $companyId, $action);
}
public static function canAccessCompany(int $companyId, ?array $user = null): bool {
$allowed = self::allowedCompanyIds($user);
if ($allowed === null) {
return true;
}
return in_array($companyId, $allowed, true);
}
public static function canAccessReport(array $report, ?array $user = null): bool {
$companyId = (int) ($report['company_id'] ?? 0);
if ($companyId <= 0) {
return self::isGlobalAdmin($user);
}
return self::canAccessCompany($companyId, $user);
}
public static function defaultCompanyId(): int {
static $cached = 0;
if ($cached > 0) {
return $cached;
}
$fromCfg = (int) cfg('rbac.default_company_id', 0);
if ($fromCfg > 0) {
$cached = $fromCfg;
return $cached;
}
$slug = (string) cfg('rbac.default_company_slug', 'default');
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT id FROM companies WHERE slug = ? LIMIT 1');
$stmt->execute([$slug]);
$id = (int) $stmt->fetchColumn();
if ($id > 0) {
$cached = $id;
return $cached;
}
$cached = 1;
return $cached;
}
/**
* Append company scope to WHERE fragments.
*
* @param list<string> $where
* @param list<mixed> $params
*/
public static function appendCompanyScope(string $column, array &$where, array &$params, ?array $user = null): void {
$allowed = self::allowedCompanyIds($user);
if ($allowed === null) {
return;
}
if ($allowed === []) {
$where[] = '0=1';
return;
}
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
$where[] = "$column IN ($placeholders)";
foreach ($allowed as $id) {
$params[] = $id;
}
}
/** Match fingerprint counts within the same company. */
public static function fingerprintCountSql(string $rAlias = 'r', string $r2Alias = 'r2'): string {
return " AND $r2Alias.company_id = $rAlias.company_id";
}
/**
* @return list<array{id:int,slug:string,name:string,role:string}>
*/
public static function fetchMemberships(int $userId): array {
$stmt = Database::pdo()->prepare(
'SELECT c.id, c.slug, c.name, m.role
FROM company_memberships m
INNER JOIN companies c ON c.id = m.company_id
WHERE m.user_id = ?
ORDER BY c.name ASC'
);
$stmt->execute([$userId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) $row['id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
'role' => (string) $row['role'],
];
}
return $out;
}
/**
* @param list<array{id:int,slug:string,name:string,role:string}> $memberships
*/
public static function pickActiveCompanyId(array $memberships): int {
if ($memberships === []) {
return self::defaultCompanyId();
}
return (int) $memberships[0]['id'];
}
/** @return array<string, mixed> session user payload */
public static function buildSessionUser(array $dbUser): array {
$uid = (int) $dbUser['id'];
$memberships = self::fetchMemberships($uid);
return [
'id' => $uid,
'username' => (string) $dbUser['username'],
'role' => self::normalizeGlobalRole((string) ($dbUser['role'] ?? self::GLOBAL_VIEWER)),
'companies' => $memberships,
'active_company_id' => self::pickActiveCompanyId($memberships),
];
}
public static function seedMembershipsForUser(int $userId, string $globalRole): void {
$pdo = Database::pdo();
$companyId = self::defaultCompanyId();
$globalRole = self::normalizeGlobalRole($globalRole);
$companyRole = self::COMPANY_MEMBER;
if (in_array($globalRole, self::GLOBAL_ADMIN_ROLES, true)) {
$companyRole = self::COMPANY_OWNER;
}
if (Database::isMysql()) {
$sql = 'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
} else {
$sql = 'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$userId, $companyId, $companyRole]);
}
private static function membershipRoleForUser(array $user, int $companyId): ?string {
foreach ($user['companies'] ?? [] as $c) {
if ((int) ($c['id'] ?? 0) === $companyId) {
return (string) ($c['role'] ?? '');
}
}
return null;
}
/** @return list<string> */
public static function privilegeSetKeys(): array {
return array_keys(self::PRIVILEGE_SETS);
}
/** @return list<string> */
public static function grantsForPrivilegeSet(string $setKey): array {
$setKey = trim($setKey);
if ($setKey === '' || $setKey === self::PRIVILEGE_SET_NONE) {
return [];
}
return self::PRIVILEGE_SETS[$setKey] ?? [];
}
public static function encodePermissionsJson(?string $privilegeSetKey): ?string {
$grants = self::grantsForPrivilegeSet((string) $privilegeSetKey);
if ($grants === []) {
return null;
}
return json_encode(['grants' => $grants], JSON_UNESCAPED_UNICODE);
}
public static function privilegeSetFromPermissionsJson(?string $raw): string {
if (!is_string($raw) || trim($raw) === '') {
return self::PRIVILEGE_SET_NONE;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return self::PRIVILEGE_SET_NONE;
}
$grants = $decoded['grants'] ?? $decoded;
if (!is_array($grants)) {
return self::PRIVILEGE_SET_NONE;
}
sort($grants);
foreach (self::PRIVILEGE_SETS as $key => $want) {
$sorted = $want;
sort($sorted);
if ($grants === $sorted) {
return $key;
}
}
return 'custom';
}
public static function isRoot(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
return self::normalizeGlobalRole((string) ($user['role'] ?? '')) === self::GLOBAL_ROOT;
}
public static function canManageRbac(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
if (self::isGlobalAdmin($user)) {
return true;
}
foreach ($user['companies'] ?? [] as $c) {
$role = (string) ($c['role'] ?? '');
if ($role === self::COMPANY_OWNER || $role === self::COMPANY_ADMIN) {
return true;
}
}
return false;
}
private static function hasPermissionOverride(array $user, int $companyId, string $action): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT permissions_json FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
);
$stmt->execute([(int) $user['id'], $companyId]);
$raw = $stmt->fetchColumn();
if (!is_string($raw) || $raw === '') {
return false;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return false;
}
$grants = $decoded['grants'] ?? $decoded;
if (!is_array($grants)) {
return false;
}
return in_array($action, $grants, true);
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
/** RBAC admin: global roles, company memberships, privilege sets. */
final class RbacAdminRepository {
private function __construct() {}
public static function buildPanel(?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !Rbac::canManageRbac($actor)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$actorId = (int) ($actor['id'] ?? 0);
$globalAdmin = Rbac::isGlobalAdmin($actor);
$companies = self::listCompaniesForActor($actor);
$companyIds = array_map(static fn($c) => (int) $c['id'], $companies);
return [
'ok' => true,
'actor' => [
'id' => $actorId,
'username' => (string) ($actor['username'] ?? ''),
'role' => (string) ($actor['role'] ?? ''),
'is_root' => Rbac::isRoot($actor),
'is_global_admin' => $globalAdmin,
],
'companies' => $companies,
'users' => self::listUsersWithMemberships($companyIds, $globalAdmin),
'global_roles' => [
Rbac::GLOBAL_ROOT,
Rbac::GLOBAL_PLATFORM_ADMIN,
Rbac::GLOBAL_VIEWER,
],
'company_roles' => [
Rbac::COMPANY_OWNER,
Rbac::COMPANY_ADMIN,
Rbac::COMPANY_MEMBER,
],
'privilege_sets' => array_map(
static fn($key) => [
'key' => $key,
'label' => str_replace('_', ' ', $key),
'grants' => Rbac::grantsForPrivilegeSet($key),
],
Rbac::privilegeSetKeys()
),
'permissions' => [
'can_edit_global_roles' => Rbac::isRoot($actor),
'can_manage_platform' => Rbac::can('manage_platform', null, $actor),
'can_manage_company_users' => $globalAdmin,
],
];
}
public static function setGlobalRole(int $targetUserId, string $role, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !Rbac::isRoot($actor)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$role = Rbac::normalizeGlobalRole($role);
$allowed = [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN, Rbac::GLOBAL_VIEWER];
if (!in_array($role, $allowed, true)) {
return ['ok' => false, 'error' => 'invalid_role'];
}
if ($targetUserId <= 0) {
return ['ok' => false, 'error' => 'invalid_user'];
}
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
$stmt->execute([$role, $targetUserId]);
if ($stmt->rowCount() < 1) {
return ['ok' => false, 'error' => 'user_not_found'];
}
if (in_array($role, [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN], true)) {
Rbac::seedMembershipsForUser($targetUserId, $role);
}
return ['ok' => true];
}
public static function setCompanyRole(int $targetUserId, int $companyId, string $role, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !self::canEditMembership($actor, $companyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$allowed = [Rbac::COMPANY_OWNER, Rbac::COMPANY_ADMIN, Rbac::COMPANY_MEMBER];
if (!in_array($role, $allowed, true)) {
return ['ok' => false, 'error' => 'invalid_role'];
}
if ($targetUserId <= 0 || $companyId <= 0) {
return ['ok' => false, 'error' => 'invalid_input'];
}
$pdo = Database::pdo();
if (Database::isMysql()) {
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE role = VALUES(role)';
} else {
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
ON CONFLICT(user_id, company_id) DO UPDATE SET role = excluded.role';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$targetUserId, $companyId, $role]);
return ['ok' => true];
}
public static function applyPrivilegeSet(int $targetUserId, int $companyId, string $setKey, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !self::canEditMembership($actor, $companyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
if ($targetUserId <= 0 || $companyId <= 0) {
return ['ok' => false, 'error' => 'invalid_input'];
}
$setKey = trim($setKey);
if ($setKey !== '' && $setKey !== Rbac::PRIVILEGE_SET_NONE && !isset(Rbac::PRIVILEGE_SETS[$setKey])) {
return ['ok' => false, 'error' => 'invalid_privilege_set'];
}
$json = Rbac::encodePermissionsJson($setKey === Rbac::PRIVILEGE_SET_NONE ? '' : $setKey);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'UPDATE company_memberships SET permissions_json = ? WHERE user_id = ? AND company_id = ?'
);
$stmt->execute([$json, $targetUserId, $companyId]);
if ($stmt->rowCount() < 1) {
return ['ok' => false, 'error' => 'membership_not_found'];
}
return ['ok' => true];
}
/** @return list<array{id:int,slug:string,name:string}> */
private static function listCompaniesForActor(array $actor): array {
$pdo = Database::pdo();
$allowed = Rbac::allowedCompanyIds($actor);
if ($allowed === null) {
$stmt = $pdo->query('SELECT id, slug, name FROM companies ORDER BY name ASC');
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
}
if ($allowed === []) {
return [];
}
$ph = implode(',', array_fill(0, count($allowed), '?'));
$stmt = $pdo->prepare("SELECT id, slug, name FROM companies WHERE id IN ($ph) ORDER BY name ASC");
$stmt->execute($allowed);
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @param list<array<string,mixed>> $rows @return list<array{id:int,slug:string,name:string}> */
private static function mapCompanies(array $rows): array {
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) $row['id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
];
}
return $out;
}
/**
* @param list<int> $companyIds
* @return list<array<string, mixed>>
*/
private static function listUsersWithMemberships(array $companyIds, bool $globalAdmin): array {
$pdo = Database::pdo();
if ($globalAdmin) {
$users = $pdo->query('SELECT id, username, role FROM users ORDER BY username ASC')->fetchAll(PDO::FETCH_ASSOC);
} else {
if ($companyIds === []) {
return [];
}
$ph = implode(',', array_fill(0, count($companyIds), '?'));
$stmt = $pdo->prepare(
"SELECT DISTINCT u.id, u.username, u.role
FROM users u
INNER JOIN company_memberships m ON m.user_id = u.id
WHERE m.company_id IN ($ph)
ORDER BY u.username ASC"
);
$stmt->execute($companyIds);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
$out = [];
foreach ($users as $u) {
$uid = (int) $u['id'];
$memberships = self::membershipsForUser($uid, $companyIds, $globalAdmin);
$out[] = [
'id' => $uid,
'username' => (string) $u['username'],
'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')),
'memberships' => $memberships,
'auth_failures' => AuthAttempts::recentFailureCount((string) $u['username']),
];
}
return $out;
}
/**
* @param list<int> $companyIds
* @return list<array<string, mixed>>
*/
private static function membershipsForUser(int $userId, array $companyIds, bool $globalAdmin): array {
$pdo = Database::pdo();
if ($globalAdmin) {
$stmt = $pdo->prepare(
'SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
FROM company_memberships m
INNER JOIN companies c ON c.id = m.company_id
WHERE m.user_id = ?
ORDER BY c.name ASC'
);
$stmt->execute([$userId]);
} else {
if ($companyIds === []) {
return [];
}
$ph = implode(',', array_fill(0, count($companyIds), '?'));
$params = array_merge([$userId], $companyIds);
$stmt = $pdo->prepare(
"SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
FROM company_memberships m
INNER JOIN companies c ON c.id = m.company_id
WHERE m.user_id = ? AND m.company_id IN ($ph)
ORDER BY c.name ASC"
);
$stmt->execute($params);
}
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$raw = isset($row['permissions_json']) ? (string) $row['permissions_json'] : '';
$out[] = [
'company_id' => (int) $row['company_id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
'role' => (string) $row['role'],
'privilege_set' => Rbac::privilegeSetFromPermissionsJson($raw),
];
}
return $out;
}
private static function canEditMembership(array $actor, int $companyId): bool {
if (Rbac::isGlobalAdmin($actor)) {
return true;
}
if (!Rbac::canAccessCompany($companyId, $actor)) {
return false;
}
$uid = (int) ($actor['id'] ?? 0);
$role = $uid > 0 ? Rbac::companyRole($uid, $companyId) : null;
return $role === Rbac::COMPANY_OWNER || $role === Rbac::COMPANY_ADMIN;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,707 @@
<?php
declare(strict_types=1);
final class ReportRepository {
private const LIST_SORT = [
'priority',
'generated_at_ms',
'received_at_ms',
'crash_type',
'device_model',
'app_version',
'fingerprint',
'fingerprint_cnt',
'rating',
];
private const GROUP_SORT = [
'cnt',
'fingerprint',
'crash_type',
'last_generated_ms',
'last_received_ms',
];
public static function insert(array $payload): void {
$pdo = Database::pdo();
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('payload json_encode failed: ' . json_last_error_msg());
}
$tags = json_encode($payload['tags'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$companyId = Rbac::defaultCompanyId();
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
$stmt = $pdo->prepare(
'INSERT INTO reports (company_id, device_id, report_id, fingerprint, crash_type, generated_at_ms,
received_at_ms, device_model, app_version, payload_json, tags_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$companyId,
$deviceId > 0 ? $deviceId : null,
$payload['report_id'] ?? uniqid('rpt_', true),
$payload['fingerprint'] ?? 'unknown',
$payload['crash_type'] ?? 'java',
(int) ($payload['generated_at_epoch_ms'] ?? 0),
(int) round(microtime(true) * 1000),
$device['model'] ?? null,
$app['version_name'] ?? null,
$json,
$tags ?: '[]',
]);
}
public static function listReports(bool $grouped): array {
return self::listPage($grouped, 1, 500, 'generated_at_ms', 'desc')['items'];
}
public static function listPage(
bool $grouped,
int $page,
int $perPage,
string $sort,
string $dir,
int $sinceMs = 0,
array $tagFilters = [],
string $tagMode = 'and'
): array {
if ($tagFilters !== []) {
return self::listFiltered(
['tags' => $tagFilters, 'tag_mode' => $tagMode],
$page,
$perPage,
$sort,
$dir,
$sinceMs
);
}
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
$offset = ($page - 1) * $perPage;
$userId = (int) (Auth::user()['id'] ?? 0);
if ($grouped) {
$sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt';
$gWhere = ['1=1'];
$gParams = [];
self::appendCompanyScope('company_id', $gWhere, $gParams);
$gWhereSql = implode(' AND ', $gWhere);
$countStmt = $pdo->prepare(
"SELECT COUNT(*) FROM (
SELECT 1 FROM reports WHERE $gWhereSql GROUP BY fingerprint, crash_type
) t"
);
$countStmt->execute($gParams);
$total = (int) $countStmt->fetchColumn();
$sql = "SELECT fingerprint, crash_type,
MIN(device_model) AS device_model,
MAX(app_version) AS app_version,
COUNT(*) AS cnt,
MAX(generated_at_ms) AS last_generated_ms,
MAX(received_at_ms) AS last_received_ms
FROM reports
WHERE $gWhereSql
GROUP BY fingerprint, crash_type
ORDER BY $sortCol $dir
LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute($gParams);
$items = $stmt->fetchAll();
foreach ($items as &$row) {
$row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0);
$row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs
? (int) $row['cnt'] : 0;
$row['rating'] = report_rating_score((int) $row['cnt'], (int) $row['fingerprint_recent_cnt'], (int) $row['cnt']);
$row['os_name'] = '';
$row['os_version'] = '';
$row['brief'] = report_brief_lines($row, true);
}
unset($row);
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => $sortCol,
'dir' => strtolower($dir),
'grouped' => true,
];
}
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
if ($sortCol === 'rating') {
$sortCol = 'fingerprint_cnt';
}
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
$where = ['1=1'];
$scopeParams = [];
self::appendCompanyScope('r.company_id', $where, $scopeParams);
$whereSql = implode(' AND ', $where);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
$countStmt->execute($scopeParams);
$total = (int) $countStmt->fetchColumn();
$sql = 'SELECT ' . self::listSelectColumns() . '
FROM reports r
' . self::fingerprintStatsJoin() . '
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE ' . $whereSql . '
ORDER BY ' . $orderBy . '
LIMIT ' . $perPage . ' OFFSET ' . $offset;
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$sinceMs, $userId], $scopeParams));
$items = $stmt->fetchAll();
self::finishListRows($items, $sinceMs, false);
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
'dir' => strtolower($dir),
'grouped' => false,
];
}
/**
* @param array{similar_to?:int,device?:string,abi?:string,os_sdk?:int,os_release?:string} $filters
*/
public static function listFiltered(
array $filters,
int $page,
int $perPage,
string $sort,
string $dir,
int $sinceMs = 0
): array {
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
$userId = (int) (Auth::user()['id'] ?? 0);
$where = ['1=1'];
$params = [];
self::appendCompanyScope('r.company_id', $where, $params);
$device = trim((string) ($filters['device'] ?? ''));
if ($device !== '') {
$like = '%' . $device . '%';
$where[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
$params[] = $like;
$params[] = $like;
}
$abi = trim((string) ($filters['abi'] ?? ''));
if ($abi !== '') {
$where[] = 'r.payload_json LIKE ?';
$params[] = '%' . $abi . '%';
}
$sdk = (int) ($filters['os_sdk'] ?? 0);
if ($sdk > 0) {
$where[] = 'r.payload_json LIKE ?';
$params[] = '%"sdk_int":' . $sdk . '%';
}
$release = trim((string) ($filters['os_release'] ?? ''));
if ($release !== '') {
$where[] = '(r.payload_json LIKE ? OR r.payload_json LIKE ?)';
$params[] = '%"release":"' . $release . '"%';
$params[] = '%"release": "' . $release . '"%';
}
$appVersion = trim((string) ($filters['app_version'] ?? ''));
if ($appVersion !== '') {
$like = '%' . $appVersion . '%';
$where[] = '(r.app_version = ? OR r.app_version LIKE ? OR r.payload_json LIKE ?)';
$params[] = $appVersion;
$params[] = $like;
$params[] = '%"version_name":"' . $appVersion . '"%';
}
$build = trim((string) ($filters['build'] ?? ''));
if ($build !== '') {
$where[] = 'r.payload_json LIKE ?';
$params[] = '%' . $build . '%';
}
self::appendTagFilters($where, $params, $filters);
$whereSql = implode(' AND ', $where);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
if ($sortCol === 'rating') {
$sortCol = 'fingerprint_cnt';
}
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
$offset = ($page - 1) * $perPage;
$sql = 'SELECT ' . self::listSelectColumns() . '
FROM reports r
' . self::fingerprintStatsJoin() . '
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE ' . $whereSql . '
ORDER BY ' . $orderBy . '
LIMIT ' . $perPage . ' OFFSET ' . $offset;
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$sinceMs, $userId], $params));
$items = $stmt->fetchAll();
self::finishListRows($items, $sinceMs, false);
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
'dir' => strtolower($dir),
'grouped' => false,
'filter' => $filters,
];
}
public static function listSimilar(
int $anchorId,
int $page,
int $perPage,
int $sinceMs = 0
): array {
$anchor = self::getById($anchorId);
if ($anchor === null) {
return [
'items' => [],
'total' => 0,
'page' => 1,
'per_page' => $perPage,
'sort' => 'similarity',
'dir' => 'desc',
'grouped' => false,
'similar_to' => $anchorId,
];
}
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$userId = (int) (Auth::user()['id'] ?? 0);
$payload = is_array($anchor['payload'] ?? null) ? $anchor['payload'] : [];
$keys = report_similarity_keys($payload);
$fp = $keys['fingerprint'];
$ct = $keys['crash_type'];
$familyLike = $keys['device_family'] !== '' ? '%' . $keys['device_family'] . '%' : null;
$excLike = $keys['exception'] !== '' ? '%' . $keys['exception'] . '%' : null;
$sigLike = $keys['signal'] !== '' ? '%' . $keys['signal'] . '%' : null;
$where = ['r.id != ?'];
$params = [$anchorId];
self::appendCompanyScope('r.company_id', $where, $params);
$or = [];
if ($fp !== '') {
$or[] = 'r.fingerprint = ?';
$params[] = $fp;
}
if ($ct !== '') {
$or[] = 'r.crash_type = ?';
$params[] = $ct;
}
if ($familyLike !== null) {
$or[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
$params[] = $familyLike;
$params[] = $familyLike;
}
if ($excLike !== null) {
$or[] = 'r.payload_json LIKE ?';
$params[] = $excLike;
}
if ($sigLike !== null) {
$or[] = 'r.payload_json LIKE ?';
$params[] = $sigLike;
}
if ($or === []) {
$or[] = '1=1';
}
$where[] = '(' . implode(' OR ', $or) . ')';
$whereSql = implode(' AND ', $where);
$sql = 'SELECT ' . self::listSelectColumns() . '
FROM reports r
' . self::fingerprintStatsJoin() . '
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE ' . $whereSql . '
ORDER BY r.received_at_ms DESC
LIMIT 1200';
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$sinceMs, $userId], $params));
$rows = $stmt->fetchAll();
$scored = [];
foreach ($rows as $row) {
$p = json_decode($row['payload_json'] ?? '', true);
if (!is_array($p)) {
$p = [];
}
$score = report_similarity_score($keys, $row, $p);
if ($score <= 0) {
continue;
}
$row['similarity_score'] = $score;
$row['similarity_tier'] = $score >= 1000 ? 'exact' : ($score >= 200 ? 'strong' : ($score >= 80 ? 'likely' : 'guess'));
$scored[] = $row;
}
usort($scored, static function (array $a, array $b): int {
$sa = (int) ($a['similarity_score'] ?? 0);
$sb = (int) ($b['similarity_score'] ?? 0);
if ($sa !== $sb) {
return $sb <=> $sa;
}
return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
});
$total = count($scored);
$offset = ($page - 1) * $perPage;
$slice = array_values(array_slice($scored, $offset, $perPage));
if ($page === 1) {
$anchorRow = null;
foreach ($scored as $row) {
if ((int) $row['id'] === $anchorId) {
$anchorRow = $row;
break;
}
}
if ($anchorRow === null) {
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
$anchorRow = [
'id' => $anchorId,
'report_id' => $anchor['report_id'] ?? '',
'fingerprint' => $anchor['fingerprint'] ?? ($payload['fingerprint'] ?? ''),
'crash_type' => $anchor['crash_type'] ?? ($payload['crash_type'] ?? ''),
'generated_at_ms' => (int) ($anchor['generated_at_ms'] ?? 0),
'received_at_ms' => (int) ($anchor['received_at_ms'] ?? 0),
'device_model' => $anchor['device_model'] ?? ($device['model'] ?? null),
'app_version' => $anchor['app_version'] ?? ($app['version_name'] ?? null),
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
'tags_json' => json_encode($anchor['tags'] ?? [], JSON_UNESCAPED_UNICODE) ?: '[]',
'viewed' => !empty($anchor['viewed']) ? 1 : 0,
'fingerprint_cnt' => 1,
'fingerprint_recent_cnt' => 0,
];
}
$anchorRow['similarity_score'] = 10000;
$anchorRow['similarity_tier'] = 'anchor';
$slice = array_values(array_filter(
$slice,
static fn (array $r): bool => (int) ($r['id'] ?? 0) !== $anchorId
));
array_unshift($slice, $anchorRow);
if (count($slice) > $perPage) {
$slice = array_slice($slice, 0, $perPage);
}
}
self::finishListRows($slice, $sinceMs, false, true);
return [
'items' => $slice,
'total' => $total + 1,
'page' => $page,
'per_page' => $perPage,
'sort' => 'similarity',
'dir' => 'desc',
'grouped' => false,
'similar_to' => $anchorId,
];
}
/** @return list<string> */
public static function searchSuggestions(string $q, int $limit = 10): array {
$tokens = search_query_tokens($q);
$prefix = $tokens[0] ?? mb_strtolower(trim($q));
if (strlen($prefix) < 2) {
return [];
}
$pdo = Database::pdo();
$where = ['1=1'];
$params = [];
self::appendCompanyScope('company_id', $where, $params);
$whereSql = implode(' AND ', $where);
$stmt = $pdo->prepare(
"SELECT payload_json, device_model, app_version, crash_type FROM reports
WHERE $whereSql ORDER BY received_at_ms DESC LIMIT 500"
);
$stmt->execute($params);
$freq = [];
while ($row = $stmt->fetch()) {
$payload = json_decode($row['payload_json'] ?? '', true);
if (!is_array($payload)) {
$payload = [];
}
$java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$candidates = array_filter([
$java['exception'] ?? null,
$row['crash_type'] ?? null,
$row['device_model'] ?? null,
$device['manufacturer'] ?? null,
$device['model'] ?? null,
$row['app_version'] ?? null,
]);
foreach ($candidates as $term) {
$term = trim((string) $term);
if ($term === '' || strlen($term) < 3) {
continue;
}
$low = mb_strtolower($term);
if (!str_contains($low, $prefix)) {
continue;
}
$freq[$term] = ($freq[$term] ?? 0) + 1;
}
if (!empty($java['message'])) {
if (preg_match_all('/[A-Za-z][A-Za-z0-9_]{3,}(?:Exception|Error)/', (string) $java['message'], $m)) {
foreach ($m[0] as $term) {
$low = mb_strtolower($term);
if (str_contains($low, $prefix)) {
$freq[$term] = ($freq[$term] ?? 0) + 2;
}
}
}
}
}
arsort($freq);
return array_slice(array_keys($freq), 0, max(1, min(20, $limit)));
}
public static function search(string $q, int $page, int $perPage, int $sinceMs = 0): array {
$tokens = search_query_tokens($q);
if ($tokens === []) {
return [
'items' => [],
'total' => 0,
'page' => 1,
'per_page' => $perPage,
'sort' => 'relevance',
'dir' => 'desc',
'grouped' => false,
'search' => $q,
];
}
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$userId = (int) (Auth::user()['id'] ?? 0);
$where = ['1=1'];
$params = [];
self::appendCompanyScope('r.company_id', $where, $params);
$orParts = [];
foreach ($tokens as $token) {
$like = '%' . $token . '%';
$orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ?
OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)';
array_push($params, $like, $like, $like, $like, $like);
}
$where[] = '(' . implode(' OR ', $orParts) . ')';
$whereSql = implode(' AND ', $where);
$sql = 'SELECT ' . self::listSelectColumns() . '
FROM reports r
' . self::fingerprintStatsJoin() . '
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE ' . $whereSql . '
ORDER BY r.received_at_ms DESC
LIMIT 1500';
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$sinceMs, $userId], $params));
$scored = [];
while ($row = $stmt->fetch()) {
$payload = json_decode($row['payload_json'] ?? '', true);
if (!is_array($payload)) {
$payload = [];
}
report_enrich_list_row($row, $sinceMs);
$score = report_search_score($tokens, $row, $payload);
if ($score <= 0) {
continue;
}
$row['search_score'] = $score;
$scored[] = $row;
}
usort($scored, static function (array $a, array $b): int {
$sa = (int) ($a['search_score'] ?? 0);
$sb = (int) ($b['search_score'] ?? 0);
if ($sa !== $sb) {
return $sb <=> $sa;
}
return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
});
$total = count($scored);
$offset = ($page - 1) * $perPage;
$slice = array_slice($scored, $offset, $perPage);
self::finishListRows($slice, $sinceMs, false, false, true);
return [
'items' => $slice,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => 'relevance',
'dir' => 'desc',
'grouped' => false,
'search' => $q,
];
}
/** @param list<array<string,mixed>> $items */
private static function finishListRows(
array &$items,
int $sinceMs,
bool $grouped,
bool $similar = false,
bool $search = false
): void {
foreach ($items as &$row) {
$row['viewed'] = (bool) ($row['viewed'] ?? false);
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
report_enrich_list_row($row, $sinceMs);
$brief = report_brief_lines($row, $grouped);
if ($similar && !empty($row['similarity_tier'])) {
$tier = (string) $row['similarity_tier'];
$labels = [
'anchor' => 'This report',
'exact' => 'Same fingerprint',
'strong' => 'Strong match',
'likely' => 'Likely similar',
'guess' => 'Possible match',
];
array_unshift($brief, ($labels[$tier] ?? $tier) . ' · score ' . (int) ($row['similarity_score'] ?? 0));
}
if ($search && !empty($row['search_score'])) {
array_unshift($brief, 'Search match · relevance ' . (int) $row['search_score']);
}
$row['brief'] = $brief;
unset($row['payload_json'], $row['tags_json']);
}
unset($row);
}
private static function buildListOrder(string $sort, string $dir): string {
$dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC';
$fpCnt = 'COALESCE(fp.fingerprint_cnt, 1)';
if ($sort === 'priority') {
return "$fpCnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC";
}
if ($sort === 'fingerprint_cnt') {
return "$fpCnt $dir, r.received_at_ms DESC";
}
return "r.$sort $dir";
}
private static function listSelectColumns(): string {
return 'r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
r.device_model, r.app_version, r.payload_json, r.tags_json,
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
COALESCE(fp.fingerprint_cnt, 1) AS fingerprint_cnt,
COALESCE(fp.fingerprint_recent_cnt, 0) AS fingerprint_recent_cnt';
}
/** One grouped scan for fingerprint counts (replaces per-row correlated subqueries). */
private static function fingerprintStatsJoin(): string {
return 'LEFT JOIN (
SELECT company_id, fingerprint,
COUNT(*) AS fingerprint_cnt,
SUM(CASE WHEN received_at_ms > ? THEN 1 ELSE 0 END) AS fingerprint_recent_cnt
FROM reports
GROUP BY company_id, fingerprint
) fp ON fp.company_id = r.company_id AND fp.fingerprint = r.fingerprint';
}
public static function setCustomTags(int $reportId, array $tags): bool {
if (self::getById($reportId) === null) {
return false;
}
$pdo = Database::pdo();
$normalized = normalize_report_tags($tags);
$json = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$stmt = $pdo->prepare('UPDATE reports SET tags_json = ? WHERE id = ?');
$stmt->execute([$json ?: '[]', $reportId]);
return true;
}
/** Distinct custom tags used across reports (for suggestions). */
public static function listTagPresets(int $limit = 40): array {
$pdo = Database::pdo();
$where = ['tags_json IS NOT NULL', "tags_json != '[]'"];
$params = [];
self::appendCompanyScope('company_id', $where, $params);
$whereSql = implode(' AND ', $where);
$stmt = $pdo->prepare("SELECT tags_json FROM reports WHERE $whereSql LIMIT 500");
$stmt->execute($params);
$rows = $stmt->fetchAll();
$map = [];
foreach ($rows as $row) {
foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) {
$map[$tag['id']] = $tag;
}
}
return array_values($map);
}
public static function markViewed(int $reportId, int $userId): void {
Database::upsertReportView(
$userId,
$reportId,
(int) round(microtime(true) * 1000)
);
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$userId = (int) (Auth::user()['id'] ?? 0);
$stmt = $pdo->prepare(
'SELECT r.*, CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed
FROM reports r
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE r.id = ?'
);
$stmt->execute([$userId, $id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
if (!Rbac::canAccessReport($row)) {
return null;
}
$decoded = json_decode($row['payload_json'], true);
$row['payload'] = is_array($decoded) ? $decoded : [];
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
$row['viewed'] = (bool) ($row['viewed'] ?? false);
return $row;
}
/**
* @param list<string> $where
* @param list<mixed> $params
*/
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
Rbac::appendCompanyScope($column, $where, $params);
}
/**
* @param list<string> $where
* @param list<mixed> $params
* @param array<string, mixed> $filters
*/
private static function appendTagFilters(array &$where, array &$params, array $filters): void {
$tags = $filters['tags'] ?? [];
if (!is_array($tags) || $tags === []) {
return;
}
$ids = TagCatalog::normalizeFilterIds($tags);
if ($ids === []) {
return;
}
$mode = strtolower((string) ($filters['tag_mode'] ?? 'and')) === 'or' ? 'or' : 'and';
$clauses = [];
foreach ($ids as $id) {
$clauses[] = '(r.tags_json LIKE ? OR r.tags_json LIKE ?)';
$params[] = '%"id":"' . $id . '"%';
$params[] = '%"id": "' . $id . '"%';
}
if ($mode === 'or') {
$where[] = '(' . implode(' OR ', $clauses) . ')';
} else {
foreach ($clauses as $clause) {
$where[] = $clause;
}
}
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/** Ephemeral Linux users on the RSSH bastion (sshd Match User ra-*). */
final class RsshBastionProvisioner {
private function __construct() {
}
public static function isEnabled(): bool {
return (bool) cfg('remote_access.rssh.provision_users', false);
}
public static function scriptPath(): string {
$configured = trim((string) cfg('remote_access.rssh.provision_script', ''));
if ($configured !== '') {
return $configured;
}
return dirname(__DIR__) . '/scripts/rssh_bastion_user.sh';
}
/** @return array{ok:bool,error?:string} */
public static function addUser(string $username, string $password): array {
$user = self::normalizeUsername($username);
if ($user === '') {
return ['ok' => false, 'error' => 'invalid_username'];
}
if (!self::isEnabled()) {
return ['ok' => true];
}
$code = self::runScript('add', $user, $password);
if ($code !== 0) {
error_log('RsshBastionProvisioner add failed for ' . $user . ' exit=' . $code);
return ['ok' => false, 'error' => 'bastion_user_add_failed'];
}
return ['ok' => true];
}
public static function removeUser(?string $username): void {
$user = self::normalizeUsername((string) $username);
if ($user === '' || !self::isEnabled()) {
return;
}
$code = self::runScript('remove', $user, '');
if ($code !== 0) {
error_log('RsshBastionProvisioner remove failed for ' . $user . ' exit=' . $code);
}
}
/** Operator command lines once device has connected reverse forward. */
public static function operatorCommands(array $session): array {
$username = trim((string) ($session['rssh_username'] ?? ''));
$remotePort = (int) ($session['rssh_remote_port'] ?? 0);
if ($username === '' || $remotePort <= 0) {
return [];
}
$host = '127.0.0.1';
return [
'shell' => sprintf('ssh -p %d %s@%s', $remotePort, $username, $host),
'sftp' => sprintf('sftp -P %d %s@%s', $remotePort, $username, $host),
'scp_example' => sprintf('scp -P %d %s@%s:/path/on/device ./', $remotePort, $username, $host),
];
}
private static function normalizeUsername(string $username): string {
$user = trim($username);
if ($user === '' || !preg_match('/^ra-[a-zA-Z0-9_-]{1,30}$/', $user)) {
return '';
}
return $user;
}
private static function runScript(string $action, string $username, string $password): int {
$script = self::scriptPath();
if (!is_file($script) || !is_executable($script)) {
error_log('RsshBastionProvisioner: script missing or not executable: ' . $script);
return 127;
}
$cmd = escapeshellarg($script) . ' ' . escapeshellarg($action) . ' ' . escapeshellarg($username);
if ($action === 'add') {
$cmd .= ' ' . escapeshellarg($password);
}
$out = [];
exec($cmd . ' 2>&1', $out, $code);
if ($code !== 0 && $out !== []) {
error_log('RsshBastionProvisioner: ' . implode("\n", $out));
}
return (int) $code;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/** Ephemeral reverse-SSH session credentials (alpha). */
final class RsshSessionProvisioner {
private function __construct() {
}
/** @return array{bastion_host:string,bastion_port:int,username:string,password:string,remote_bind_port:int,local_forward_port:int} */
public static function allocate(string $sessionId): array {
$host = (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org');
$port = max(1, min(65535, (int) cfg('remote_access.rssh.bastion_port', 443)));
$prefix = (string) cfg('remote_access.rssh.username_prefix', 'ra-');
$username = $prefix . preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
if (strlen($username) > 32) {
$username = substr($username, 0, 32);
}
$password = bin2hex(random_bytes(16));
$remotePort = self::allocateRemotePort($sessionId);
$localPort = max(1, min(65535, (int) cfg('remote_access.rssh.local_forward_port', 8022)));
return [
'bastion_host' => $host,
'bastion_port' => $port,
'username' => $username,
'password' => $password,
'remote_bind_port' => $remotePort,
'local_forward_port' => $localPort,
];
}
public static function allocateRemotePort(string $sessionId): int {
$min = (int) cfg('remote_access.rssh.remote_port_min', 18000);
$max = (int) cfg('remote_access.rssh.remote_port_max', 18999);
if ($max <= $min) {
$max = $min + 999;
}
$span = $max - $min + 1;
return $min + (abs(crc32($sessionId . '|' . time())) % $span);
}
/** @param array<string, mixed> $session */
public static function endpointFromSession(array $session): string {
$host = (string) ($session['rssh_bastion_host'] ?? cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'));
$port = (int) ($session['rssh_bastion_port'] ?? cfg('remote_access.rssh.bastion_port', 443));
if ($port === 443) {
return $host;
}
return $host . ':' . $port;
}
}

View File

@@ -0,0 +1,481 @@
<?php
declare(strict_types=1);
/**
* Operator admin for url_shortener DB — SPEC §20 (session RBAC, not public API).
*/
final class ShortLinksRepository {
private const MAX_URL_LEN = 2048;
/** @return array{ok: bool, error?: string} */
public static function availability(): array {
if (!UrlShortenerDatabase::enabled()) {
return ['ok' => false, 'error' => 'disabled'];
}
if (!UrlShortenerDatabase::ping()) {
return ['ok' => false, 'error' => 'db_unreachable'];
}
return ['ok' => true];
}
/** @return list<array<string,mixed>> */
public static function listBearers(): array {
$pdo = UrlShortenerDatabase::pdo();
$rows = $pdo->query(
'SELECT b.id, b.label, b.trusted, b.can_temporary, b.can_permanent,
b.rate_limit_per_hour, b.revoked_at, b.created_at,
COUNT(l.id) AS link_count,
SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at < NOW() THEN 1 ELSE 0 END) AS expired_link_count
FROM bearer_tokens b
LEFT JOIN links l ON l.bearer_id = b.id
GROUP BY b.id
ORDER BY b.id DESC'
)->fetchAll();
return array_map(static fn(array $r) => self::formatBearerRow($r), $rows);
}
/** @return list<array<string,mixed>> */
public static function listLinks(int $bearerId, int $limit = 100): array {
$pdo = UrlShortenerDatabase::pdo();
$limit = max(1, min(500, $limit));
$stmt = $pdo->prepare(
'SELECT id, slug, origin, ttl_seconds, expires_at, created_at, accessed_at, access_count
FROM links WHERE bearer_id = ? ORDER BY id DESC LIMIT ' . $limit
);
$stmt->execute([$bearerId]);
$base = UrlShortenerDatabase::publicBase();
$out = [];
foreach ($stmt->fetchAll() as $row) {
$slug = (string) $row['slug'];
$expired = self::rowExpired($row);
$out[] = [
'id' => (int) $row['id'],
'slug' => $slug,
'short_url' => $base . '/' . $slug,
'qr_url' => $base . '/api/v1/qr/' . $slug . '.png',
'origin' => (string) $row['origin'],
'ttl_seconds' => (int) $row['ttl_seconds'],
'expires_at' => $row['expires_at'],
'expired' => $expired,
'created_at' => $row['created_at'],
'accessed_at' => $row['accessed_at'],
'access_count' => (int) $row['access_count'],
];
}
return $out;
}
/** @return list<array<string,mixed>> */
public static function listAudit(int $limit = 50): array {
$pdo = UrlShortenerDatabase::pdo();
$limit = max(1, min(200, $limit));
$rows = $pdo->query(
'SELECT id, event, bearer_id, slug, ip, detail, created_at
FROM audit_log ORDER BY id DESC LIMIT ' . $limit
)->fetchAll();
return array_map(static function (array $r): array {
return [
'id' => (int) $r['id'],
'event' => (string) $r['event'],
'bearer_id' => $r['bearer_id'] !== null ? (int) $r['bearer_id'] : null,
'slug' => $r['slug'],
'ip' => (string) $r['ip'],
'detail' => (string) ($r['detail'] ?? ''),
'created_at' => $r['created_at'],
];
}, $rows);
}
/**
* @return array{ok: true, bearer_id: int, token: string, signer_key?: string, signer_id?: int}|array{ok: false, error: string}
*/
public static function mintBearer(
string $label,
bool $canTemporary = true,
bool $canPermanent = false,
int $rateLimit = 1000,
bool $mintSigner = false
): array {
$label = trim($label);
if ($label === '') {
return ['ok' => false, 'error' => 'missing_label'];
}
if (strlen($label) > 128) {
return ['ok' => false, 'error' => 'label_too_long'];
}
$token = bin2hex(random_bytes(24));
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'INSERT INTO bearer_tokens (token_hash, label, trusted, can_temporary, can_permanent, rate_limit_per_hour)
VALUES (?, ?, 1, ?, ?, ?)'
);
$stmt->execute([
hash('sha256', $token),
$label,
$canTemporary ? 1 : 0,
$canPermanent ? 1 : 0,
max(1, min(100000, $rateLimit)),
]);
$id = (int) $pdo->lastInsertId();
self::audit('admin_mint', $id, null, 'label=' . $label);
$out = ['ok' => true, 'bearer_id' => $id, 'token' => $token];
if ($canPermanent && $mintSigner) {
$signer = self::mintSigner($label . '-signer');
$out['signer_id'] = $signer['id'];
$out['signer_key'] = $signer['key'];
}
return $out;
}
/** @return list<array<string,mixed>> */
public static function listSigners(): array {
$pdo = UrlShortenerDatabase::pdo();
$rows = $pdo->query(
'SELECT id, label, can_permanent, revoked_at, created_at
FROM signer_keys ORDER BY id DESC'
)->fetchAll();
return array_map(static function (array $r): array {
return [
'id' => (int) $r['id'],
'label' => (string) $r['label'],
'can_permanent' => (int) ($r['can_permanent'] ?? 0) === 1,
'revoked_at' => $r['revoked_at'],
'active' => empty($r['revoked_at']),
'created_at' => $r['created_at'],
];
}, $rows);
}
/**
* @return array{ok: true, id: int, key: string}|array{ok: false, error: string}
*/
public static function mintSigner(string $label): array {
$label = trim($label);
if ($label === '') {
return ['ok' => false, 'error' => 'missing_label'];
}
if (strlen($label) > 128) {
return ['ok' => false, 'error' => 'label_too_long'];
}
$key = bin2hex(random_bytes(24));
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'INSERT INTO signer_keys (key_hash, label, can_permanent) VALUES (?, ?, 1)'
);
$stmt->execute([hash('sha256', $key), $label]);
$id = (int) $pdo->lastInsertId();
self::audit('admin_mint_signer', null, null, 'signer_id=' . $id . ' label=' . $label);
return ['ok' => true, 'id' => $id, 'key' => $key];
}
public static function revokeSigner(int $signerId): bool {
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'UPDATE signer_keys SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL'
);
$stmt->execute([$signerId]);
if ($stmt->rowCount() < 1) {
return false;
}
self::audit('admin_revoke_signer', null, null, 'signer_id=' . $signerId);
return true;
}
public static function revokeBearer(int $bearerId): bool {
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'UPDATE bearer_tokens SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL'
);
$stmt->execute([$bearerId]);
if ($stmt->rowCount() < 1) {
return false;
}
self::audit('admin_revoke', $bearerId, null, 'revoked');
return true;
}
/** @return array{links: int, audit: int} */
public static function purgeExpired(): array {
$pdo = UrlShortenerDatabase::pdo();
$links = (int) $pdo->exec(
'DELETE FROM links WHERE expires_at IS NOT NULL AND expires_at < NOW()'
);
$audit = (int) $pdo->exec(
'DELETE FROM audit_log WHERE created_at < (NOW() - INTERVAL 90 DAY)'
);
self::audit('admin_purge', null, null, "links=$links audit=$audit");
return ['links' => $links, 'audit' => $audit];
}
/**
* @return array{ok: true, short_url: string, slug: string, qr_url: string, ttl: string, url?: string}|array{ok: false, error: string, message?: string}
*/
public static function createLink(
int $bearerId,
string $rawUrl,
int $ttlSeconds = 86400,
string $signerKey = ''
): array {
$bearer = self::findBearerById($bearerId);
if ($bearer === null) {
return ['ok' => false, 'error' => 'unknown_bearer'];
}
if ($bearer['revoked_at'] !== null && $bearer['revoked_at'] !== '') {
return ['ok' => false, 'error' => 'bearer_revoked'];
}
if ((int) ($bearer['trusted'] ?? 0) !== 1) {
return ['ok' => false, 'error' => 'bearer_untrusted'];
}
try {
$normalized = self::normalizeUrl($rawUrl);
} catch (InvalidArgumentException $e) {
return ['ok' => false, 'error' => 'invalid_url', 'message' => $e->getMessage()];
}
$originHash = hash('sha256', $normalized);
$cached = self::findActiveLink($bearerId, $originHash);
if ($cached !== null) {
return self::linkSuccess($normalized, $cached);
}
if (self::findExpiredLink($bearerId, $originHash) !== null) {
return ['ok' => false, 'error' => 'ttl_expired'];
}
if ($ttlSeconds < 0) {
$ttlSeconds = 86400;
}
$signerId = null;
if ($ttlSeconds === 0) {
$signer = self::findValidSigner($signerKey);
if ($signer === null) {
return ['ok' => false, 'error' => 'signer_required'];
}
if ((int) ($bearer['can_permanent'] ?? 0) !== 1) {
return ['ok' => false, 'error' => 'bearer_no_permanent'];
}
$signerId = (int) $signer['id'];
} elseif ((int) ($bearer['can_temporary'] ?? 0) !== 1) {
return ['ok' => false, 'error' => 'bearer_no_temporary'];
}
$limit = max(1, (int) ($bearer['rate_limit_per_hour'] ?? 1000));
if (self::countCreatesLastHour($bearerId) >= $limit) {
return ['ok' => false, 'error' => 'rate_limited'];
}
$slug = self::allocateSlug($bearerId, $normalized);
$expiresAt = $ttlSeconds <= 0 ? null : date('Y-m-d H:i:s', time() + $ttlSeconds);
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'INSERT INTO links (bearer_id, signer_id, slug, origin, origin_hash, ttl_seconds, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$bearerId,
$signerId,
$slug,
$normalized,
$originHash,
max(0, $ttlSeconds),
$expiresAt,
]);
self::audit('admin_shorten', $bearerId, $slug, $ttlSeconds === 0 ? 'permanent' : 'created');
$row = self::findLinkBySlug($slug);
if ($row === null) {
return ['ok' => false, 'error' => 'insert_failed'];
}
return self::linkSuccess($normalized, $row);
}
/** @param array<string,mixed> $row */
private static function formatBearerRow(array $row): array {
return [
'id' => (int) $row['id'],
'label' => (string) $row['label'],
'trusted' => (int) ($row['trusted'] ?? 0) === 1,
'can_temporary' => (int) ($row['can_temporary'] ?? 0) === 1,
'can_permanent' => (int) ($row['can_permanent'] ?? 0) === 1,
'rate_limit_per_hour' => (int) ($row['rate_limit_per_hour'] ?? 1000),
'revoked_at' => $row['revoked_at'],
'active' => empty($row['revoked_at']),
'created_at' => $row['created_at'],
'link_count' => (int) ($row['link_count'] ?? 0),
'expired_link_count' => (int) ($row['expired_link_count'] ?? 0),
];
}
/** @return array<string,mixed>|null */
private static function findValidSigner(string $signerKey): ?array {
if (trim($signerKey) === '') {
return null;
}
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'SELECT * FROM signer_keys WHERE key_hash = ? AND revoked_at IS NULL AND can_permanent = 1 LIMIT 1'
);
$stmt->execute([hash('sha256', $signerKey)]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @return array<string,mixed>|null */
private static function findBearerById(int $id): ?array {
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare('SELECT * FROM bearer_tokens WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @return array<string,mixed>|null */
private static function findActiveLink(int $bearerId, string $originHash): ?array {
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1'
);
$stmt->execute([$bearerId, $originHash]);
$row = $stmt->fetch();
if ($row === false || self::rowExpired($row)) {
return null;
}
return $row;
}
/** @return array<string,mixed>|null */
private static function findExpiredLink(int $bearerId, string $originHash): ?array {
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1'
);
$stmt->execute([$bearerId, $originHash]);
$row = $stmt->fetch();
if ($row === false || !self::rowExpired($row)) {
return null;
}
return $row;
}
/** @return array<string,mixed>|null */
private static function findLinkBySlug(string $slug): ?array {
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare('SELECT * FROM links WHERE slug = ? LIMIT 1');
$stmt->execute([$slug]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @param array<string,mixed> $row */
private static function rowExpired(array $row): bool {
$exp = $row['expires_at'] ?? null;
if ($exp === null || $exp === '') {
return false;
}
return strtotime((string) $exp) <= time();
}
private static function countCreatesLastHour(int $bearerId): int {
$pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare(
'SELECT COUNT(*) AS c FROM links WHERE bearer_id = ? AND created_at >= (NOW() - INTERVAL 1 HOUR)'
);
$stmt->execute([$bearerId]);
$row = $stmt->fetch();
return (int) ($row['c'] ?? 0);
}
private static function allocateSlug(int $bearerId, string $normalized): string {
$material = $bearerId . "\x1f" . $normalized;
$candidates = [substr(hash('sha256', $material), 0, 12)];
for ($i = 1; $i <= 15; $i++) {
$candidates[] = substr(hash('sha256', $material . "\x1e" . $i), 0, 12);
}
$pdo = UrlShortenerDatabase::pdo();
foreach ($candidates as $slug) {
$stmt = $pdo->prepare('SELECT 1 FROM links WHERE slug = ? LIMIT 1');
$stmt->execute([$slug]);
if ($stmt->fetch() === false) {
return $slug;
}
}
throw new RuntimeException('slug exhaustion');
}
/** @param array<string,mixed> $row */
private static function linkSuccess(string $normalized, array $row): array {
$slug = (string) $row['slug'];
$base = UrlShortenerDatabase::publicBase();
$exp = $row['expires_at'] ?? null;
$ttl = '0';
if ($exp !== null && $exp !== '') {
$ttl = (string) max(0, strtotime((string) $exp) - time());
}
return [
'ok' => true,
'url' => $normalized,
'short_url' => $base . '/' . $slug,
'slug' => $slug,
'qr_url' => $base . '/api/v1/qr/' . $slug . '.png',
'ttl' => $ttl,
];
}
private static function normalizeUrl(string $raw): string {
$url = trim($raw);
if ($url === '') {
throw new InvalidArgumentException('empty url');
}
if (strlen($url) > self::MAX_URL_LEN) {
throw new InvalidArgumentException('url too long');
}
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
throw new InvalidArgumentException('malformed url');
}
$scheme = strtolower((string) $parts['scheme']);
if ($scheme !== 'http' && $scheme !== 'https') {
throw new InvalidArgumentException('unsupported scheme');
}
$host = strtolower((string) $parts['host']);
self::assertHostAllowed($host);
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
$path = $parts['path'] ?? '';
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
return $scheme . '://' . $host . $port . $path . $query . $fragment;
}
private static function assertHostAllowed(string $host): void {
if ($host === 'localhost' || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
throw new InvalidArgumentException('blocked host');
}
if ($host === 'metadata.google.internal' || $host === 'metadata') {
throw new InvalidArgumentException('blocked host');
}
if (filter_var($host, FILTER_VALIDATE_IP)) {
self::assertIpAllowed($host);
return;
}
$resolved = @gethostbyname($host);
if ($resolved !== $host && filter_var($resolved, FILTER_VALIDATE_IP)) {
self::assertIpAllowed($resolved);
}
}
private static function assertIpAllowed(string $ip): void {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
throw new InvalidArgumentException('blocked host');
}
}
private static function audit(string $event, ?int $bearerId, ?string $slug, string $detail): void {
$pdo = UrlShortenerDatabase::pdo();
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$stmt = $pdo->prepare(
'INSERT INTO audit_log (event, bearer_id, slug, ip, detail) VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$event, $bearerId, $slug, $ip, $detail]);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/** Workflow triage tags — multiple labels per report allowed (e.g. duplicate + fixed). */
final class TagCatalog {
/** @var list<array{id:string,label:string,bg:string}>|null */
private static ?array $workflow = null;
/** @return list<array{id:string,label:string,bg:string}> */
public static function workflowPresets(): array {
if (self::$workflow !== null) {
return self::$workflow;
}
self::$workflow = [
['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3d8bfd'],
['id' => 'fixed', 'label' => 'fixed', 'bg' => '#047857'],
['id' => 'rejected', 'label' => 'rejected', 'bg' => '#dc2626'],
['id' => 'done', 'label' => 'done', 'bg' => '#5c6b82'],
['id' => 'not-reproducible', 'label' => 'not reproducible', 'bg' => '#d97706'],
['id' => 'not-a-bug', 'label' => 'not a bug', 'bg' => '#6b7280'],
['id' => 'duplicate', 'label' => 'duplicate', 'bg' => '#6d28d9'],
];
return self::$workflow;
}
/** @return array<string, true> */
public static function workflowIds(): array {
$map = [];
foreach (self::workflowPresets() as $tag) {
$map[$tag['id']] = true;
}
return $map;
}
public static function isWorkflowId(string $id): bool {
return isset(self::workflowIds()[$id]);
}
/**
* @param list<string> $raw from query (?tag=fixed&tag=duplicate)
* @return list<string>
*/
public static function normalizeFilterIds(array $raw): array {
$out = [];
$seen = [];
foreach ($raw as $item) {
$id = strtolower(trim((string) $item));
$id = preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? '';
$id = trim($id, '-');
if ($id === '' || strlen($id) > 32 || isset($seen[$id])) {
continue;
}
if (isset(array_flip(report_reserved_tag_ids())[$id])) {
continue;
}
$seen[$id] = true;
$out[] = $id;
if (count($out) >= 8) {
break;
}
}
return $out;
}
/**
* Workflow presets first, then distinct custom tags from DB (excluding workflow ids).
*
* @return list<array{id:string,label:string,bg:string}>
*/
public static function editorPresets(int $historicalLimit = 32): array {
$map = [];
foreach (self::workflowPresets() as $tag) {
$map[$tag['id']] = $tag;
}
foreach (ReportRepository::listTagPresets($historicalLimit + 20) as $tag) {
$id = (string) ($tag['id'] ?? '');
if ($id === '' || isset($map[$id])) {
continue;
}
$map[$id] = $tag;
}
return array_values($map);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/** Classify external URLs (Google Suite labels now; OAuth/embed later). */
final class TicketAttachmentProvider {
public const GENERIC = 'generic';
public const GOOGLE_DOCS = 'google_docs';
public const GOOGLE_SHEETS = 'google_sheets';
public const GOOGLE_SLIDES = 'google_slides';
public const GOOGLE_DRIVE = 'google_drive';
public const GOOGLE_MEET = 'google_meet';
public const GOOGLE_CHAT = 'google_chat';
public const GOOGLE_FORMS = 'google_forms';
/** @return array{id:string,label:string}> */
public static function labels(): array {
return [
self::GENERIC => ['id' => self::GENERIC, 'label' => 'Link'],
self::GOOGLE_DOCS => ['id' => self::GOOGLE_DOCS, 'label' => 'Google Docs'],
self::GOOGLE_SHEETS => ['id' => self::GOOGLE_SHEETS, 'label' => 'Google Sheets'],
self::GOOGLE_SLIDES => ['id' => self::GOOGLE_SLIDES, 'label' => 'Google Slides'],
self::GOOGLE_DRIVE => ['id' => self::GOOGLE_DRIVE, 'label' => 'Google Drive'],
self::GOOGLE_MEET => ['id' => self::GOOGLE_MEET, 'label' => 'Google Meet'],
self::GOOGLE_CHAT => ['id' => self::GOOGLE_CHAT, 'label' => 'Google Chat'],
self::GOOGLE_FORMS => ['id' => self::GOOGLE_FORMS, 'label' => 'Google Forms'],
];
}
public static function labelFor(string $provider): string {
$labels = self::labels();
return $labels[$provider]['label'] ?? 'Link';
}
public static function detectFromUrl(string $url): string {
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
if ($host === '') {
return self::GENERIC;
}
if (str_contains($host, 'docs.google.com')) {
return self::GOOGLE_DOCS;
}
if (str_contains($host, 'sheets.google.com')) {
return self::GOOGLE_SHEETS;
}
if (str_contains($host, 'slides.google.com')) {
return self::GOOGLE_SLIDES;
}
if (str_contains($host, 'drive.google.com')) {
return self::GOOGLE_DRIVE;
}
if (str_contains($host, 'meet.google.com')) {
return self::GOOGLE_MEET;
}
if (str_contains($host, 'chat.google.com')) {
return self::GOOGLE_CHAT;
}
if (str_contains($host, 'forms.gle') || str_contains($host, 'forms.google.com')) {
return self::GOOGLE_FORMS;
}
return self::GENERIC;
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
final class TicketAttachmentRepository {
private const MAX_BYTES = 16777216; // 16 MiB
/** @return list<string> */
private static function allowedExtensions(): array {
return [
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg',
'pdf', 'txt', 'log', 'md', 'json', 'xml', 'zip',
'mp4', 'webm', 'mp3', 'wav',
];
}
public static function storageRoot(): string {
$dir = dirname(__DIR__) . '/storage/ticket_attachments';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function listForTicket(int $ticketId): array {
// Do not call TicketRepository::getById here — avoids recursion from mapDetailRow.
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT * FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC'
);
$stmt->execute([$ticketId]);
$bp = Auth::basePath();
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[] = self::mapRow($row, $bp);
}
return $out;
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$ticket = TicketRepository::getById((int) $row['ticket_id']);
if (!$ticket) {
return null;
}
return self::mapRow($row, Auth::basePath());
}
public static function addLink(int $ticketId, string $author, string $url, string $title = ''): array {
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
throw new RuntimeException('not found');
}
$url = trim($url);
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
throw new InvalidArgumentException('valid url required');
}
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
if (!in_array($scheme, ['http', 'https'], true)) {
throw new InvalidArgumentException('http or https url required');
}
$provider = TicketAttachmentProvider::detectFromUrl($url);
if ($title === '') {
$title = TicketAttachmentProvider::labelFor($provider);
}
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider,
storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms)
VALUES (?, \'link\', ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?)'
);
$stmt->execute([
$ticketId,
mb_substr($title, 0, 256),
$url,
$provider,
mb_substr(trim($author), 0, 64),
$now,
]);
self::touchTicket($ticketId, $now);
return self::getById((int) $pdo->lastInsertId()) ?? [];
}
/** @param array{name:string,type:string,tmp_name:string,error:int,size:int} $file */
public static function addFile(int $ticketId, string $author, array $file, string $title = ''): array {
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
throw new RuntimeException('not found');
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new InvalidArgumentException('upload failed');
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > self::MAX_BYTES) {
throw new InvalidArgumentException('file too large (max 16 MiB)');
}
$original = basename((string) ($file['name'] ?? 'file'));
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, self::allowedExtensions(), true)) {
throw new InvalidArgumentException('file type not allowed');
}
$mime = (string) ($file['type'] ?? 'application/octet-stream');
$storageName = bin2hex(random_bytes(16)) . '.' . $ext;
$dir = self::storageRoot() . '/' . $ticketId;
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$dest = $dir . '/' . $storageName;
if (!move_uploaded_file((string) $file['tmp_name'], $dest)) {
throw new RuntimeException('could not store file');
}
if ($title === '') {
$title = $original;
}
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider,
storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms)
VALUES (?, \'file\', ?, NULL, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$ticketId,
mb_substr($title, 0, 256),
TicketAttachmentProvider::GENERIC,
$storageName,
mb_substr($original, 0, 256),
mb_substr($mime, 0, 128),
$size,
mb_substr(trim($author), 0, 64),
$now,
]);
self::touchTicket($ticketId, $now);
return self::getById((int) $pdo->lastInsertId()) ?? [];
}
public static function delete(int $id, string $username): void {
$att = self::getById($id);
if (!$att) {
throw new RuntimeException('not found');
}
$ticket = TicketRepository::getById((int) $att['ticket_id']);
if (!$ticket) {
throw new RuntimeException('not found');
}
$can = !empty($ticket['can_edit'])
|| strcasecmp((string) ($att['created_by'] ?? ''), $username) === 0
|| Auth::canEditTags();
if (!$can) {
throw new RuntimeException('forbidden');
}
if ($att['kind'] === 'file' && !empty($att['storage_name'])) {
$path = self::storageRoot() . '/' . $att['ticket_id'] . '/' . $att['storage_name'];
if (is_file($path)) {
@unlink($path);
}
}
$pdo = Database::pdo();
$pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([$id]);
self::touchTicket((int) $att['ticket_id'], (int) round(microtime(true) * 1000));
}
public static function filePath(array $attachment): ?string {
if (($attachment['kind'] ?? '') !== 'file' || empty($attachment['storage_name'])) {
return null;
}
$path = self::storageRoot() . '/' . (int) $attachment['ticket_id'] . '/' . $attachment['storage_name'];
return is_file($path) ? $path : null;
}
private static function touchTicket(int $ticketId, int $now): void {
Database::pdo()->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]);
}
/** @param array<string, mixed> $row */
private static function mapRow(array $row, string $basePath): array {
$id = (int) $row['id'];
$kind = (string) $row['kind'];
$provider = (string) ($row['provider'] ?? TicketAttachmentProvider::GENERIC);
$item = [
'id' => $id,
'ticket_id' => (int) $row['ticket_id'],
'kind' => $kind,
'title' => (string) ($row['title'] ?? ''),
'external_url' => (string) ($row['external_url'] ?? ''),
'provider' => $provider,
'provider_label' => TicketAttachmentProvider::labelFor($provider),
'original_name' => (string) ($row['original_name'] ?? ''),
'mime_type' => (string) ($row['mime_type'] ?? ''),
'size_bytes' => (int) ($row['size_bytes'] ?? 0),
'created_by' => (string) ($row['created_by'] ?? ''),
'created_at_ms' => (int) ($row['created_at_ms'] ?? 0),
'download_url' => $kind === 'file'
? $basePath . '/api/ticket_attachment.php?id=' . $id
: null,
];
return $item;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
final class TicketCommentRepository {
public static function listForTicket(int $ticketId): array {
// Do not call TicketRepository::getById here — mapDetailRow already loads comments.
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT id, ticket_id, author_username, body, created_at_ms, updated_at_ms
FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC'
);
$stmt->execute([$ticketId]);
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[] = [
'id' => (int) $row['id'],
'ticket_id' => (int) $row['ticket_id'],
'author_username' => (string) $row['author_username'],
'body' => (string) $row['body'],
'created_at_ms' => (int) $row['created_at_ms'],
'updated_at_ms' => (int) $row['updated_at_ms'],
];
}
return $out;
}
public static function add(int $ticketId, string $author, string $body): array {
$ticket = TicketRepository::getById($ticketId);
if (!$ticket) {
throw new RuntimeException('not found');
}
$body = trim($body);
if ($body === '') {
throw new InvalidArgumentException('comment body required');
}
$author = mb_substr(trim($author), 0, 64);
if ($author === '') {
throw new InvalidArgumentException('author required');
}
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO ticket_comments (ticket_id, author_username, body, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$ticketId, $author, $body, $now, $now]);
$id = (int) $pdo->lastInsertId();
$pdo->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]);
return [
'id' => $id,
'ticket_id' => $ticketId,
'author_username' => $author,
'body' => $body,
'created_at_ms' => $now,
'updated_at_ms' => $now,
];
}
}

View File

@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
final class TicketRepository {
private const LIST_SORT = [
'opened_at_ms',
'updated_at_ms',
'title',
'rating',
'app_version',
'device_model',
'owner_username',
];
private static ?array $ticketColumns = null;
public static function insert(array $payload): int {
$err = TicketTagCatalog::validate(is_array($payload['tags'] ?? null) ? $payload['tags'] : []);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$pdo = Database::pdo();
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
$tags = TicketTagCatalog::normalize($payload['tags'] ?? []);
$tagsJson = json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false || $tagsJson === false) {
throw new RuntimeException('json_encode failed');
}
$now = (int) round(microtime(true) * 1000);
$opened = (int) ($payload['opened_at_epoch_ms'] ?? $now);
$companyId = Rbac::defaultCompanyId();
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
$title = trim((string) ($payload['title'] ?? ''));
if ($title === '') {
throw new InvalidArgumentException('title required');
}
$brief = trim((string) ($payload['brief'] ?? ''));
$body = (string) ($payload['body'] ?? $brief);
$rating = max(0, min(5, (int) ($payload['rating'] ?? 0)));
$owner = trim((string) ($payload['owner'] ?? $payload['owner_username'] ?? ''));
$verified = trim((string) ($payload['verified_by'] ?? $payload['verified_by_username'] ?? ''));
$osName = device_os_name($device);
$osVer = ticket_format_os_version($device);
$stmt = $pdo->prepare(
'INSERT INTO tickets (company_id, device_id, ticket_id, title, brief, body,
opened_at_ms, created_at_ms, updated_at_ms, owner_username, verified_by_username,
device_model, app_package, app_version, os_name, os_version, rating, tags_json, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$companyId,
$deviceId > 0 ? $deviceId : null,
$payload['ticket_id'] ?? uniqid('tkt_', true),
mb_substr($title, 0, 256),
$brief !== '' ? $brief : null,
$body,
$opened,
$now,
$now,
mb_substr($owner, 0, 64),
$verified !== '' ? mb_substr($verified, 0, 64) : null,
$device['model'] ?? null,
$app['package'] ?? 'com.foxx.androidcast',
$app['version_name'] ?? null,
$osName !== '' ? $osName : null,
$osVer !== '' ? $osVer : null,
$rating,
$tagsJson,
$json,
]);
return (int) $pdo->lastInsertId();
}
public static function listPage(int $page, int $perPage, string $sort, string $dir, string $tagFilter = ''): array {
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'opened_at_ms';
$offset = ($page - 1) * $perPage;
$where = ['1=1'];
$params = [];
self::appendCompanyScope('company_id', $where, $params);
if ($tagFilter !== '') {
$where[] = 'tags_json LIKE ?';
$params[] = '%"id":"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagFilter) . '"%';
}
$whereSql = implode(' AND ', $where);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE $whereSql");
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$select = 'id, ticket_id, title, brief, opened_at_ms, updated_at_ms, created_at_ms,
owner_username, verified_by_username, device_model, app_package, app_version,
os_name, os_version, rating, tags_json';
if (self::hasTicketColumn('workflow_state')) {
$select .= ', workflow_state';
}
if (self::hasTicketColumn('assignees_json')) {
$select .= ', assignees_json';
}
$sql = "SELECT $select
FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = [];
foreach ($stmt->fetchAll() as $row) {
$items[] = self::mapListRow($row);
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => $sortCol,
'dir' => strtolower($dir),
];
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$where = ['id = ?'];
$params = [$id];
self::appendCompanyScope('company_id', $where, $params);
$stmt = $pdo->prepare(
'SELECT * FROM tickets WHERE ' . implode(' AND ', $where) . ' LIMIT 1'
);
$stmt->execute($params);
$row = $stmt->fetch();
if (!$row) {
return null;
}
return self::mapDetailRow($row);
}
public static function setCustomTags(int $id, array $tagsIn): void {
$ticket = self::getById($id);
if (!$ticket) {
throw new RuntimeException('not found');
}
$err = TicketTagCatalog::validate($tagsIn);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$tags = TicketTagCatalog::normalize($tagsIn);
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE tickets SET tags_json = ?, updated_at_ms = ? WHERE id = ?');
$stmt->execute([
json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$now,
$id,
]);
}
/** @param array<string, mixed> $fields */
public static function update(int $id, array $fields): void {
$ticket = self::getById($id);
if (!$ticket) {
throw new RuntimeException('not found');
}
$sets = [];
$params = [];
if (isset($fields['title'])) {
$title = trim((string) $fields['title']);
if ($title === '') {
throw new InvalidArgumentException('title required');
}
$sets[] = 'title = ?';
$params[] = mb_substr($title, 0, 256);
}
if (array_key_exists('brief', $fields)) {
$sets[] = 'brief = ?';
$params[] = trim((string) $fields['brief']) ?: null;
}
if (isset($fields['body'])) {
$sets[] = 'body = ?';
$params[] = (string) $fields['body'];
}
if (isset($fields['rating'])) {
$sets[] = 'rating = ?';
$params[] = max(0, min(5, (int) $fields['rating']));
}
if (isset($fields['owner_username'])) {
$sets[] = 'owner_username = ?';
$params[] = mb_substr(trim((string) $fields['owner_username']), 0, 64);
}
if (array_key_exists('verified_by_username', $fields)) {
$v = trim((string) $fields['verified_by_username']);
$sets[] = 'verified_by_username = ?';
$params[] = $v !== '' ? mb_substr($v, 0, 64) : null;
}
if (isset($fields['workflow_state']) && self::hasTicketColumn('workflow_state')) {
$state = TicketWorkflow::normalize((string) $fields['workflow_state']);
$sets[] = 'workflow_state = ?';
$params[] = $state;
$tags = TicketWorkflow::mergeTagsForState(
is_array($ticket['tags'] ?? null) ? $ticket['tags'] : [],
$state
);
$sets[] = 'tags_json = ?';
$params[] = json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
if (array_key_exists('assignees', $fields) && self::hasTicketColumn('assignees_json')) {
$assignees = self::normalizeAssignees($fields['assignees']);
$err = UserRepository::validateUsernames(
array_map(static fn (array $a): string => $a['username'], $assignees)
);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$json = json_encode($assignees, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('json_encode failed');
}
$sets[] = 'assignees_json = ?';
$params[] = $json;
}
if ($sets === []) {
return;
}
$now = (int) round(microtime(true) * 1000);
$sets[] = 'updated_at_ms = ?';
$params[] = $now;
$params[] = $id;
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE tickets SET ' . implode(', ', $sets) . ' WHERE id = ?');
$stmt->execute($params);
}
public static function listTagPresets(): array {
return TicketTagCatalog::presets();
}
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
$allowed = Rbac::allowedCompanyIds();
if ($allowed === null) {
return;
}
if ($allowed === []) {
$where[] = '0=1';
return;
}
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
$where[] = "$column IN ($placeholders)";
foreach ($allowed as $id) {
$params[] = $id;
}
}
private static function hasTicketColumn(string $column): bool {
if (self::$ticketColumns === null) {
self::$ticketColumns = Database::columnNames(Database::pdo(), 'tickets');
}
return in_array($column, self::$ticketColumns, true);
}
/** @param array<string, mixed> $row */
private static function mapListRow(array $row): array {
$tags = parse_report_tags($row['tags_json'] ?? '[]');
$pkg = (string) ($row['app_package'] ?? 'com.foxx.androidcast');
$ver = (string) ($row['app_version'] ?? '');
$os = trim(((string) ($row['os_name'] ?? '')) . ' ' . ((string) ($row['os_version'] ?? '')));
return [
'id' => (int) $row['id'],
'ticket_id' => (string) $row['ticket_id'],
'title' => (string) $row['title'],
'brief' => (string) ($row['brief'] ?? ''),
'opened_at_ms' => (int) $row['opened_at_ms'],
'updated_at_ms' => (int) $row['updated_at_ms'],
'owner_username' => (string) ($row['owner_username'] ?? ''),
'verified_by_username' => (string) ($row['verified_by_username'] ?? ''),
'device_model' => (string) ($row['device_model'] ?? ''),
'app_package' => $pkg,
'app_version' => $ver,
'os_name' => (string) ($row['os_name'] ?? ''),
'os_version' => (string) ($row['os_version'] ?? ''),
'env_brief' => ticket_env_brief($pkg, $ver, (string) ($row['os_name'] ?? ''), (string) ($row['os_version'] ?? '')),
'rating' => (int) ($row['rating'] ?? 0),
'tags' => $tags,
'workflow_state' => TicketWorkflow::normalize((string) ($row['workflow_state'] ?? 'triage')),
'assignees' => self::parseAssigneesJson($row['assignees_json'] ?? '[]'),
];
}
/** @param array<string, mixed> $row */
private static function mapDetailRow(array $row): array {
$list = self::mapListRow($row);
$payload = json_decode($row['payload_json'] ?? '', true);
$list['body'] = (string) ($row['body'] ?? '');
$list['created_at_ms'] = (int) ($row['created_at_ms'] ?? 0);
$list['payload'] = is_array($payload) ? $payload : [];
$list['can_edit'] = Auth::canEditTicket($list);
try {
$list['comments'] = TicketCommentRepository::listForTicket((int) $row['id']);
} catch (Throwable $e) {
$list['comments'] = [];
}
try {
$list['attachments'] = TicketAttachmentRepository::listForTicket((int) $row['id']);
} catch (Throwable $e) {
$list['attachments'] = [];
}
return $list;
}
/** @return list<array{username:string,role:string}> */
public static function parseAssigneesJson(string $json): array {
$data = json_decode($json, true);
if (!is_array($data)) {
return [];
}
return self::normalizeAssignees($data);
}
/**
* @param mixed $raw
* @return list<array{username:string,role:string}>
*/
public static function normalizeAssignees($raw): array {
if (!is_array($raw)) {
return [];
}
$out = [];
$seen = [];
foreach ($raw as $item) {
if (is_string($item)) {
$item = ['username' => $item, 'role' => ''];
}
if (!is_array($item)) {
continue;
}
$user = trim((string) ($item['username'] ?? ''));
if ($user === '') {
continue;
}
$key = strtolower($user);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = [
'username' => mb_substr($user, 0, 64),
'role' => mb_substr(trim((string) ($item['role'] ?? '')), 0, 32),
];
}
return $out;
}
}
function ticket_format_os_version(array $device): string {
$rel = (string) ($device['release'] ?? '');
$sdk = (int) ($device['sdk_int'] ?? 0);
if ($rel !== '' && $sdk > 0) {
return $rel . ' (API ' . $sdk . ')';
}
if ($rel !== '') {
return $rel;
}
if ($sdk > 0) {
return 'API ' . $sdk;
}
return '';
}
function ticket_env_brief(string $pkg, string $version, string $osName, string $osVersion): string {
$app = str_contains($pkg, 'androidcast') ? 'Android Cast' : ($pkg !== '' ? $pkg : 'app');
$line = trim($app . ($version !== '' ? ' ' . $version : ''));
$os = trim($osName . ($osVersion !== '' ? ' · ' . $osVersion : ''));
return trim($line . ($os !== '' ? ' · ' . $os : ''));
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/** Workflow tags for tickets (must include exactly one meta tag: ticket or issue). */
final class TicketTagCatalog {
public const META_ID = 'ticket';
/** @var list<string> */
public const META_IDS = ['ticket', 'issue'];
/** @var array<string, array{label:string,bg:string}> */
private const META = [
'ticket' => ['label' => 'ticket', 'bg' => '#6366f1'],
'issue' => ['label' => 'issue', 'bg' => '#0d9488'],
];
/** @var array<string, array{label:string,bg:string}> */
private const STATUS = [
'open' => ['label' => 'open', 'bg' => '#22c55e'],
'in-progress' => ['label' => 'in progress', 'bg' => '#3b82f6'],
'blocked' => ['label' => 'blocked', 'bg' => '#b45309'],
'wont-fix' => ['label' => "won't fix", 'bg' => '#6b7280'],
'closed' => ['label' => 'closed', 'bg' => '#6b7280'],
'rejected' => ['label' => 'rejected', 'bg' => '#b91c1c'],
'confirmed' => ['label' => 'confirmed', 'bg' => '#047857'],
'postponed' => ['label' => 'postponed', 'bg' => '#a16207'],
'urgent' => ['label' => 'urgent', 'bg' => '#dc2626'],
];
/** @return list<string> */
public static function statusIds(): array {
return array_keys(self::STATUS);
}
/** @return list<array{id:string,label:string,bg:string}> */
public static function presets(): array {
$out = [];
foreach (self::META as $id => $def) {
$out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
}
foreach (self::STATUS as $id => $def) {
$out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
}
return $out;
}
public static function isMetaId(string $id): bool {
return isset(self::META[$id]);
}
/**
* @param list<mixed> $tags
* @return list<array{id:string,label:string,bg:string}>
*/
public static function normalize(array $tags): array {
$normalized = normalize_report_tags($tags);
$metaId = null;
$hasStatus = false;
$rest = [];
foreach ($normalized as $t) {
if (self::isMetaId($t['id'])) {
$metaId = $t['id'];
continue;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
$rest[] = $t;
}
if ($metaId === null) {
$metaId = self::META_ID;
}
$meta = self::META[$metaId];
$out = [
['id' => $metaId, 'label' => $meta['label'], 'bg' => $meta['bg']],
];
foreach ($rest as $t) {
$out[] = $t;
}
if (!$hasStatus) {
$out[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'];
}
return $out;
}
public static function validate(array $tags): ?string {
$normalized = normalize_report_tags($tags);
$metaCount = 0;
$hasStatus = false;
foreach ($normalized as $t) {
if (self::isMetaId($t['id'])) {
$metaCount++;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
}
if ($metaCount === 0) {
return 'tags must include a type tag (ticket or issue)';
}
if ($metaCount > 1) {
return 'only one type tag allowed: ticket or issue';
}
if (!$hasStatus) {
return 'tags must include a status tag (open, in-progress, closed, …)';
}
return null;
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/** Canonical lifecycle (separate from free-form tags). */
final class TicketWorkflow {
public const TRIAGE = 'triage';
public const IN_PROGRESS = 'in_progress';
public const BLOCKED = 'blocked';
public const WONT_FIX = 'wont_fix';
public const DONE = 'done';
public const CANCELLED = 'cancelled';
/** @return list<string> */
public static function all(): array {
return [
self::TRIAGE,
self::IN_PROGRESS,
self::BLOCKED,
self::WONT_FIX,
self::DONE,
self::CANCELLED,
];
}
public static function normalize(string $state): string {
$s = strtolower(trim($state));
return in_array($s, self::all(), true) ? $s : self::TRIAGE;
}
/** @return array{id:string,label:string,bg:string} */
public static function labelDef(string $state): array {
$map = [
self::TRIAGE => ['id' => self::TRIAGE, 'label' => 'triage', 'bg' => '#64748b'],
self::IN_PROGRESS => ['id' => self::IN_PROGRESS, 'label' => 'in progress', 'bg' => '#3b82f6'],
self::BLOCKED => ['id' => self::BLOCKED, 'label' => 'blocked', 'bg' => '#b45309'],
self::WONT_FIX => ['id' => self::WONT_FIX, 'label' => "won't fix", 'bg' => '#6b7280'],
self::DONE => ['id' => self::DONE, 'label' => 'done', 'bg' => '#047857'],
self::CANCELLED => ['id' => self::CANCELLED, 'label' => 'cancelled', 'bg' => '#b91c1c'],
];
return $map[self::normalize($state)] ?? $map[self::TRIAGE];
}
/** Map workflow to legacy status tag id for tag bar sync. */
public static function statusTagId(string $state): string {
return match (self::normalize($state)) {
self::IN_PROGRESS => 'in-progress',
self::BLOCKED => 'blocked',
self::WONT_FIX => 'wont-fix',
self::DONE => 'closed',
self::CANCELLED => 'rejected',
default => 'open',
};
}
/**
* Replace workflow/status tags; keep meta + custom tags.
*
* @param list<array{id:string,label:string,bg:string}> $tags
* @return list<array{id:string,label:string,bg:string}>
*/
public static function mergeTagsForState(array $tags, string $state): array {
$keep = [];
$statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all());
foreach (normalize_report_tags($tags) as $t) {
if (TicketTagCatalog::isMetaId($t['id'])) {
$keep[] = $t;
continue;
}
if (in_array($t['id'], $statusIds, true)) {
continue;
}
$keep[] = $t;
}
$sid = self::statusTagId($state);
$preset = TicketTagCatalog::presets();
foreach ($preset as $p) {
if ($p['id'] === $sid) {
$keep[] = $p;
break;
}
}
return TicketTagCatalog::normalize($keep);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/** PDO to MariaDB `url_shortener` (separate from crashes DB). */
final class UrlShortenerDatabase {
private static ?PDO $pdo = null;
/** @var array<string,mixed>|null|false */
private static $resolvedConfig = null;
/** @return array<string,mixed>|null */
private static function resolveConfig(): ?array {
if (self::$resolvedConfig !== null) {
return self::$resolvedConfig === false ? null : self::$resolvedConfig;
}
$block = cfg('url_shortener', null);
if (is_array($block)) {
if (!empty($block['enabled']) && is_array($block['db']['mysql'] ?? null)) {
self::$resolvedConfig = $block;
return $block;
}
if (is_array($block['db']['mysql'] ?? null) && ($block['db']['mysql']['database'] ?? '') !== '') {
$block['enabled'] = true;
self::$resolvedConfig = $block;
return $block;
}
}
$crashDb = cfg('db.mysql', []);
if (!is_array($crashDb) || ($crashDb['password'] ?? '') === '') {
self::$resolvedConfig = false;
return null;
}
$m = $crashDb;
$m['database'] = 'url_shortener';
$candidate = [
'enabled' => true,
'public_base' => 'https://s.f0xx.org',
'db' => ['mysql' => $m],
];
if (!self::pingMysql($m)) {
self::$resolvedConfig = false;
return null;
}
self::$resolvedConfig = $candidate;
return $candidate;
}
/** @param array<string,mixed> $m */
private static function pingMysql(array $m): bool {
try {
$db = (string) ($m['database'] ?? '');
if ($db === '') {
return false;
}
$charset = (string) ($m['charset'] ?? 'utf8mb4');
$socket = trim((string) ($m['socket'] ?? ''));
if ($socket !== '') {
$dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset);
} else {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$m['host'] ?? '127.0.0.1',
(int) ($m['port'] ?? 3306),
$db,
$charset
);
}
$pdo = new PDO($dsn, (string) ($m['username'] ?? ''), (string) ($m['password'] ?? ''), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->query('SELECT 1');
return true;
} catch (Throwable) {
return false;
}
}
public static function enabled(): bool {
return self::resolveConfig() !== null;
}
public static function publicBase(): string {
$block = self::resolveConfig();
if ($block !== null) {
return rtrim((string) ($block['public_base'] ?? 'https://s.f0xx.org'), '/');
}
return rtrim((string) cfg('url_shortener.public_base', 'https://s.f0xx.org'), '/');
}
public static function pdo(): PDO {
if (!self::enabled()) {
throw new RuntimeException('url_shortener not configured');
}
if (self::$pdo !== null) {
return self::$pdo;
}
$block = self::resolveConfig();
$m = is_array($block) ? ($block['db']['mysql'] ?? []) : [];
if (!is_array($m) || ($m['database'] ?? '') === '') {
throw new RuntimeException('url_shortener.db.mysql not configured');
}
$db = (string) $m['database'];
$charset = (string) ($m['charset'] ?? 'utf8mb4');
$socket = trim((string) ($m['socket'] ?? ''));
if ($socket !== '') {
$dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset);
} else {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$m['host'] ?? '127.0.0.1',
(int) ($m['port'] ?? 3306),
$db,
$charset
);
}
self::$pdo = new PDO($dsn, (string) ($m['username'] ?? ''), (string) ($m['password'] ?? ''), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return self::$pdo;
}
public static function ping(): bool {
if (!self::enabled()) {
return false;
}
try {
self::pdo()->query('SELECT 1');
return true;
} catch (Throwable) {
return false;
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
final class UserRepository {
/** @return list<array{username:string,role:string}> */
public static function listForAssignees(): array {
$pdo = Database::pdo();
$stmt = $pdo->query('SELECT username, role FROM users ORDER BY username ASC');
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[] = [
'username' => (string) $row['username'],
'role' => (string) ($row['role'] ?? 'viewer'),
];
}
return $out;
}
/** @param list<string> $usernames */
public static function validateUsernames(array $usernames): ?string {
$want = [];
foreach ($usernames as $u) {
$u = trim((string) $u);
if ($u !== '') {
$want[strtolower($u)] = $u;
}
}
if ($want === []) {
return null;
}
$pdo = Database::pdo();
$stmt = $pdo->query('SELECT username FROM users');
$have = [];
foreach ($stmt->fetchAll() as $row) {
$have[strtolower((string) $row['username'])] = true;
}
foreach ($want as $lower => $orig) {
if (!isset($have[$lower])) {
return 'unknown user: ' . $orig;
}
}
return null;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/** WG client IPs + server AllowedIPs from config (matches live wg0 on BE). */
final class WireGuardAddressPool {
private function __construct() {
}
public static function defaultEndpoint(): string {
return (string) cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:45340');
}
public static function serverAllowedIps(): string {
return (string) cfg('remote_access.wg_server_allowed_ips', '172.200.2.1/32');
}
public static function allocateClientAddress(string $sessionId): string {
$prefix = (string) cfg('remote_access.wg_client_ip_prefix', '172.200.2.');
$min = max(2, (int) cfg('remote_access.wg_client_ip_min', 10));
$max = max($min, (int) cfg('remote_access.wg_client_ip_max', 250));
$span = $max - $min + 1;
$host = $min + (abs(crc32($sessionId)) % $span);
return $prefix . $host . '/32';
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/** Alpine BE WireGuard peer lifecycle (wg set) for remote-access sessions. */
final class WireGuardPeerProvisioner {
private function __construct() {}
public static function isEnabled(): bool {
return (bool) cfg('remote_access.provision_peers', true);
}
public static function interfaceName(): string {
return (string) cfg('remote_access.wg_interface', 'wg0');
}
public static function publicKeyFromPrivate(string $privateKey): string {
$priv = trim($privateKey);
if ($priv === '') {
return '';
}
$pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: '');
return $pub;
}
public static function addClientPeer(string $clientPublicKey, string $clientAddressCidr): bool {
if (!self::isEnabled()) {
return true;
}
$pub = trim($clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return false;
}
$iface = self::interfaceName();
$allowed = trim($clientAddressCidr);
if ($allowed === '') {
$allowed = WireGuardAddressPool::allocateClientAddress('default');
}
$code = self::runWg(
'set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' allowed-ips ' . escapeshellarg($allowed)
);
return $code === 0;
}
public static function removeClientPeer(?string $clientPublicKey): void {
if (!self::isEnabled()) {
return;
}
$pub = trim((string) $clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return;
}
$iface = self::interfaceName();
self::runWg(
'set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' remove'
);
}
/** @return list<array{public_key: string, allowed_ips: string}> */
public static function listPeerDumpRows(): array {
if (!self::isEnabled()) {
return [];
}
$iface = self::interfaceName();
$prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t");
$cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg show ' . escapeshellarg($iface) . ' dump 2>/dev/null';
$raw = @shell_exec($cmd);
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$rows = [];
foreach (preg_split('/\r\n|\n|\r/', trim($raw)) as $line) {
if ($line === '' || str_starts_with($line, 'interface:')) {
continue;
}
$cols = explode("\t", $line);
if (count($cols) < 4) {
continue;
}
$pub = trim($cols[0]);
if ($pub === '') {
continue;
}
$rows[] = [
'public_key' => $pub,
'allowed_ips' => trim((string) ($cols[3] ?? '')),
];
}
return $rows;
}
/**
* Remove live wg peers not tied to pending/active sessions (or with empty AllowedIPs).
*
* @param list<string> $keepPublicKeys
* @return list<array{public_key: string, allowed_ips: string, reason: string}>
*/
public static function pruneOrphanPeers(array $keepPublicKeys, bool $dryRun = false): array {
if (!self::isEnabled()) {
return [];
}
$keep = [];
foreach ($keepPublicKeys as $key) {
$pub = trim((string) $key);
if ($pub !== '') {
$keep[$pub] = true;
}
}
$pruned = [];
foreach (self::listPeerDumpRows() as $row) {
$pub = $row['public_key'];
$allowed = trim($row['allowed_ips']);
$broken = ($allowed === '' || $allowed === '(none)');
$orphan = !isset($keep[$pub]);
if (!$orphan && !$broken) {
continue;
}
if (!$dryRun) {
self::removeClientPeer($pub);
}
$pruned[] = [
'public_key' => $pub,
'allowed_ips' => $allowed,
'reason' => $broken ? 'broken_allowed_ips' : 'orphan',
];
}
return $pruned;
}
private static function runWg(string $args): int {
$prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t");
$cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg ' . $args;
exec($cmd . ' 2>&1', $out, $code);
if ($code !== 0) {
error_log('WireGuardPeerProvisioner: ' . $cmd . ' failed: ' . implode(' ', $out));
}
return $code;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/** Live WireGuard transfer counters from `wg show dump` (BE data plane). */
final class WireGuardTrafficStats {
private function __construct() {}
/** @return array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}> */
public static function peerMap(): array {
static $cache = null;
static $cacheAt = 0;
if ($cache !== null && (time() - $cacheAt) < 5) {
return $cache;
}
$cache = self::loadPeerMap();
$cacheAt = time();
return $cache;
}
/** @return array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}|null */
public static function forPublicKey(string $publicKey): ?array {
$pub = trim($publicKey);
if ($pub === '') {
return null;
}
return self::peerMap()[$pub] ?? null;
}
/** @return array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}> */
private static function loadPeerMap(): array {
$iface = (string) cfg('remote_access.wg_interface', 'wg0');
$prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t");
$cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg show ' . escapeshellarg($iface) . ' dump 2>/dev/null';
$raw = @shell_exec($cmd);
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$out = [];
foreach (preg_split('/\r\n|\n|\r/', trim($raw)) as $line) {
if ($line === '' || str_starts_with($line, 'interface:')) {
continue;
}
$cols = explode("\t", $line);
if (count($cols) < 7) {
continue;
}
$pub = trim($cols[0]);
if ($pub === '') {
continue;
}
$out[$pub] = [
'rx_bytes' => (int) ($cols[5] ?? 0),
'tx_bytes' => (int) ($cols[6] ?? 0),
'latest_handshake' => (int) ($cols[4] ?? 0),
'endpoint' => trim((string) ($cols[2] ?? '')),
];
}
return $out;
}
public static function formatBytes(int $bytes): string {
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1024 * 1024) {
return round($bytes / 1024, 1) . ' KiB';
}
if ($bytes < 1024 * 1024 * 1024) {
return round($bytes / (1024 * 1024), 2) . ' MiB';
}
return round($bytes / (1024 * 1024 * 1024), 2) . ' GiB';
}
}

View File

@@ -0,0 +1,618 @@
<?php
/*
* package examples/crash_reporter/backend/src/bootstrap.php
* bootstrap.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, 45 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 13f47a5da844f6ab0e4de9a934ec4e3a3892401c5b8b43123aa1539131b1ab48
*/
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/platform/shared_session.php';
require_once dirname(__DIR__, 3) . '/platform/footer.php';
$configPath = __DIR__ . '/../config/config.php';
if (!is_file($configPath)) {
$configPath = __DIR__ . '/../config/config.example.php';
}
$config = require $configPath;
$sessionName = $config['session_name'] ?? 'ac_crash_sess';
$sessionPath = $config['session_cookie_path'] ?? '/app/androidcast_project';
if (!function_exists('session_start')) {
if (PHP_SAPI !== 'cli') {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
$phpV = PHP_VERSION;
echo "PHP session extension is not loaded (SAPI=" . PHP_SAPI . ", PHP {$phpV}).\n"
. "CLI check: php -m | grep -i session\n"
. " php -v # match package major (php81-* vs php82-*)\n"
. "Alpine: apk add php81-session php81-pdo # or php82-session for PHP 8.2\n"
. " rc-service php-fpm81 restart\n"
. "Debian: apt install php-session\n";
exit(1);
}
// CLI cron (purge_remote_access.php, verify): PDO only; no web login cookie.
} elseif (PHP_SAPI !== 'cli') {
platform_start_session($sessionName, $sessionPath);
}
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Rbac.php';
require_once __DIR__ . '/DeviceRepository.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/AuthEmailSchema.php';
require_once __DIR__ . '/AuthMailer.php';
require_once __DIR__ . '/AuthRegistration.php';
require_once __DIR__ . '/AuthCrypto.php';
require_once __DIR__ . '/AuthTotp.php';
require_once __DIR__ . '/AuthFactors.php';
require_once __DIR__ . '/AuthAttempts.php';
require_once __DIR__ . '/ReportRepository.php';
require_once __DIR__ . '/TagCatalog.php';
require_once __DIR__ . '/TicketTagCatalog.php';
require_once __DIR__ . '/TicketWorkflow.php';
require_once __DIR__ . '/TicketAttachmentProvider.php';
require_once __DIR__ . '/TicketAttachmentRepository.php';
require_once __DIR__ . '/TicketCommentRepository.php';
require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/RemoteAccessRepository.php';
require_once __DIR__ . '/LiveCastRepository.php';
require_once __DIR__ . '/LiveCastSignalingRepository.php';
require_once __DIR__ . '/LiveCastSignalingRepository.php';
require_once __DIR__ . '/UrlShortenerDatabase.php';
require_once __DIR__ . '/ShortLinksRepository.php';
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
require_once __DIR__ . '/WireGuardAddressPool.php';
require_once __DIR__ . '/WireGuardTrafficStats.php';
require_once __DIR__ . '/RsshSessionProvisioner.php';
require_once __DIR__ . '/RsshBastionProvisioner.php';
require_once __DIR__ . '/AuthTwoFactorPage.php';
require_once __DIR__ . '/AnalyticsHead.php';
function cfg(string $key, $default = null) {
global $config;
$parts = explode('.', $key);
$v = $config;
foreach ($parts as $p) {
if (!is_array($v) || !array_key_exists($p, $v)) {
return $default;
}
$v = $v[$p];
}
return $v;
}
function h(mixed $s): string {
if ($s === null || $s === false) {
return '';
}
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** json_encode that never returns false (substitutes bad UTF-8). */
function safe_json_encode(mixed $data, int $flags = 0): string {
$flags |= JSON_INVALID_UTF8_SUBSTITUTE;
$json = json_encode($data, $flags);
return is_string($json) ? $json : 'null';
}
function json_out(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/** Resolve web route: auth pages at project root; console pages under base_path. */
function resolve_console_route(string $uri): string {
$projectRoot = rtrim((string) cfg('project_base_path', '/app/androidcast_project'), '/');
$base = rtrim((string) cfg('base_path', ''), '/');
if ($projectRoot !== '' && str_starts_with($uri, $projectRoot)) {
$suffix = substr($uri, strlen($projectRoot)) ?: '/';
$candidate = rtrim(strtok($suffix, '?') ?: '/', '/') ?: '/';
if (preg_match('#^/(login|logout|register|two-factor|verify-email)(?:/|$)#', $candidate)) {
return $candidate;
}
}
if ($base !== '' && str_starts_with($uri, $base)) {
$uri = substr($uri, strlen($base)) ?: '/';
}
return rtrim(strtok($uri, '?') ?: '/', '/') ?: '/';
}
/** Public URL prefix for the active console view (graphs vs crashes). */
function console_base_path(?string $view = null): string {
$view = $view ?? (string) ($_GET['view'] ?? '');
if ($view === 'graphs') {
$graphs = trim((string) cfg('graphs_base_path', ''));
if ($graphs !== '') {
return rtrim($graphs, '/');
}
$crashes = rtrim((string) cfg('base_path', ''), '/');
if (str_ends_with($crashes, '/issues')) {
return substr($crashes, 0, -strlen('/issues')) . '/graphs';
}
if (str_ends_with($crashes, '/crashes')) {
return substr($crashes, 0, -strlen('/crashes')) . '/graphs';
}
return '/app/androidcast_project/graphs';
}
return rtrim((string) cfg('base_path', ''), '/');
}
/**
* Raw POST body for crash ingest. Supports plain JSON or Content-Encoding: gzip
* (when nginx gunzip is off or the client talks to PHP directly).
*
* @return string|null decoded JSON text, null if empty, false if gzip decode failed
*/
function read_crash_upload_body(): string|false|null {
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return null;
}
$encoding = $_SERVER['HTTP_CONTENT_ENCODING'] ?? '';
if ($encoding !== '' && stripos($encoding, 'gzip') !== false) {
$decoded = @gzdecode($raw);
return $decoded === false ? false : $decoded;
}
return $raw;
}
/** Short lines for expandable list preview (not full report detail). */
function report_brief_lines(array $row, bool $grouped): array {
if ($grouped) {
return array_filter([
sprintf('%d reports · %s', (int) ($row['cnt'] ?? 0), $row['crash_type'] ?? ''),
'Fingerprint: ' . ($row['fingerprint'] ?? ''),
trim(sprintf(
'%s · app %s',
$row['device_model'] ?? '',
$row['app_version'] ?? ''
), ' ·'),
]);
}
$p = json_decode($row['payload_json'] ?? '', true);
if (!is_array($p)) {
$p = [];
}
$lines = [
sprintf(
'%s · %s · %s',
$row['crash_type'] ?? ($p['crash_type'] ?? ''),
$p['scenario'] ?? '—',
substr($row['fingerprint'] ?? ($p['fingerprint'] ?? ''), 0, 16)
),
trim(sprintf(
'%s %s · %s v%s',
$p['device']['manufacturer'] ?? '',
$row['device_model'] ?? ($p['device']['model'] ?? ''),
$p['app']['package'] ?? 'com.foxx.androidcast',
$row['app_version'] ?? ($p['app']['version_name'] ?? '')
)),
];
if (!empty($p['java'])) {
$j = $p['java'];
$exc = trim(($j['exception'] ?? '') . (!empty($j['message']) ? ': ' . $j['message'] : ''));
$lines[] = $exc !== '' ? $exc : 'Java crash';
$frames = $j['stack_frames'] ?? [];
if (!empty($frames[0])) {
$lines[] = (string) $frames[0];
}
} elseif (!empty($p['native'])) {
$lines[] = 'Native: ' . ($p['native']['signal'] ?? 'crash');
$bt = $p['native']['backtrace'] ?? [];
if (!empty($bt[0])) {
$lines[] = (string) $bt[0];
}
}
if (!empty($p['runtime_context']['last_activity'])) {
$lines[] = 'Activity: ' . $p['runtime_context']['last_activity'];
}
return array_values(array_filter($lines, static fn ($l) => trim($l) !== '' && trim($l) !== '·'));
}
/** @return array{label:string,bg:string} */
function crash_kind_tag(string $crashType): array {
$t = strtolower($crashType);
return match ($t) {
'native' => ['label' => 'NDK', 'bg' => '#b45309'],
'java' => ['label' => 'Java', 'bg' => '#6d28d9'],
'android' => ['label' => 'Android', 'bg' => '#047857'],
default => ['label' => ucfirst($t ?: '?'), 'bg' => '#5c6b82'],
};
}
/** Human-readable ABI list from device payload. */
function format_device_abis(mixed $abis): string {
if (!is_array($abis)) {
return is_scalar($abis) ? trim((string) $abis) : '';
}
$parts = [];
foreach ($abis as $abi) {
if (is_scalar($abi)) {
$s = trim((string) $abi);
if ($s !== '') {
$parts[] = $s;
}
}
}
return implode(', ', $parts);
}
function device_display_name(array $device): string {
return trim(((string) ($device['manufacturer'] ?? '')) . ' ' . ((string) ($device['model'] ?? '')));
}
/** Shorter family name for “similar device” (drops last token when 3+ words). */
function device_model_family(string $manufacturer, string $model): string {
$full = trim($manufacturer . ' ' . $model);
if ($full === '') {
return '';
}
$parts = preg_split('/\s+/', $full) ?: [];
if (count($parts) <= 2) {
return $full;
}
return implode(' ', array_slice($parts, 0, -1));
}
/** Keys used to rank similar crashes (from payload). */
function report_similarity_keys(array $payload): array {
$java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
$native = is_array($payload['native'] ?? null) ? $payload['native'] : [];
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$exc = (string) ($java['exception'] ?? '');
$msg = (string) ($java['message'] ?? '');
$topic = '';
if ($msg !== '' && preg_match('/\b([A-Za-z][A-Za-z0-9_]*(?:Exception|Error))\b/', $msg, $m)) {
$topic = $m[1];
}
return [
'fingerprint' => (string) ($payload['fingerprint'] ?? ''),
'crash_type' => strtolower((string) ($payload['crash_type'] ?? '')),
'exception' => $exc,
'exception_topic' => $topic,
'signal' => (string) ($native['signal'] ?? ''),
'device_family' => device_model_family(
(string) ($device['manufacturer'] ?? ''),
(string) ($device['model'] ?? '')
),
'device_model' => (string) ($device['model'] ?? ''),
];
}
/** Higher = more similar. */
function report_similarity_score(array $anchorKeys, array $row, array $rowPayload): int {
$score = 0;
$fp = (string) ($row['fingerprint'] ?? $rowPayload['fingerprint'] ?? '');
$ct = strtolower((string) ($row['crash_type'] ?? $rowPayload['crash_type'] ?? ''));
if ($anchorKeys['fingerprint'] !== '' && $fp === $anchorKeys['fingerprint']) {
$score += 1000;
}
if ($anchorKeys['crash_type'] !== '' && $ct === $anchorKeys['crash_type']) {
$score += 80;
}
$java = is_array($rowPayload['java'] ?? null) ? $rowPayload['java'] : [];
$native = is_array($rowPayload['native'] ?? null) ? $rowPayload['native'] : [];
$exc = (string) ($java['exception'] ?? '');
if ($anchorKeys['exception'] !== '' && $exc !== '' && $exc === $anchorKeys['exception']) {
$score += 120;
}
if ($anchorKeys['exception_topic'] !== '') {
$msg = (string) ($java['message'] ?? '');
if ($msg !== '' && stripos($msg, $anchorKeys['exception_topic']) !== false) {
$score += 60;
}
}
$sig = (string) ($native['signal'] ?? '');
if ($anchorKeys['signal'] !== '' && $sig !== '' && $sig === $anchorKeys['signal']) {
$score += 100;
}
$device = is_array($rowPayload['device'] ?? null) ? $rowPayload['device'] : [];
$family = device_model_family(
(string) ($device['manufacturer'] ?? ''),
(string) ($device['model'] ?? '')
);
if ($anchorKeys['device_family'] !== '' && $family !== '') {
if ($family === $anchorKeys['device_family']) {
$score += 50;
} elseif (str_starts_with($family, $anchorKeys['device_family'])
|| str_starts_with($anchorKeys['device_family'], $family)) {
$score += 25;
}
}
$dm = (string) ($row['device_model'] ?? $device['model'] ?? '');
if ($anchorKeys['device_model'] !== '' && $dm !== '' && $dm === $anchorKeys['device_model']) {
$score += 30;
}
return $score;
}
/** OS product name (Android, Linux, …) — not device manufacturer. */
function device_os_name(array $device): string {
if (!empty($device['os_name'])) {
return trim((string) $device['os_name']);
}
if (!empty($device['os'])) {
return trim((string) $device['os']);
}
$sdk = (int) ($device['sdk_int'] ?? 0);
$blob = strtolower(implode(' ', array_filter([
(string) ($device['product'] ?? ''),
(string) ($device['device'] ?? ''),
(string) ($device['model'] ?? ''),
])));
if ($sdk > 0 || str_contains($blob, 'android')) {
return 'Android';
}
if (str_contains($blob, 'linux')) {
return 'Linux';
}
if (($device['release'] ?? '') !== '') {
return 'Android';
}
return 'Android';
}
/** Enrich list row with OS fields and rating inputs (keeps payload_json until unset). */
function report_enrich_list_row(array &$row, int $sinceMs = 0): void {
$p = json_decode($row['payload_json'] ?? '', true);
if (!is_array($p)) {
$p = [];
}
$d = $p['device'] ?? [];
$row['os_name'] = device_os_name($d);
$rel = (string) ($d['release'] ?? '');
$sdk = (int) ($d['sdk_int'] ?? 0);
if ($rel !== '' && $sdk > 0) {
$row['os_version'] = $rel . ' (API ' . $sdk . ')';
} elseif ($rel !== '') {
$row['os_version'] = $rel;
} elseif ($sdk > 0) {
$row['os_version'] = 'API ' . $sdk;
} else {
$row['os_version'] = '';
}
$row['scenario'] = (string) ($p['scenario'] ?? '');
$total = (int) ($row['fingerprint_cnt'] ?? 1);
$recent = (int) ($row['fingerprint_recent_cnt'] ?? 0);
if ($sinceMs > 0 && $recent === 0 && !empty($row['received_at_ms'])) {
$recent = (int) $row['received_at_ms'] > $sinceMs ? 1 : 0;
}
$row['rating'] = report_rating_score($total, $recent, (int) ($row['cnt'] ?? 0));
}
function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int $groupCnt = 0): int {
$t = $groupCnt > 0 ? $groupCnt : max(1, $fingerprintTotal);
$r = $fingerprintRecent;
$score = 0;
if ($t >= 40) {
$score = 5;
} elseif ($t >= 20) {
$score = 4;
} elseif ($t >= 10) {
$score = 3;
} elseif ($t >= 4) {
$score = 2;
} elseif ($t >= 2) {
$score = 1;
}
if ($r >= 8) {
$score = min(5, $score + 2);
} elseif ($r >= 3) {
$score = min(5, $score + 1);
}
return max(0, min(5, $score));
}
/** @return list<string> */
function search_query_tokens(string $q): array {
$q = mb_strtolower(trim($q));
if ($q === '') {
return [];
}
$parts = preg_split('/\s+/u', $q) ?: [];
$out = [];
foreach ($parts as $part) {
$t = preg_replace('/[^a-z0-9_.-]+/i', '', $part) ?? '';
if (strlen($t) >= 2) {
$out[] = $t;
}
}
return array_values(array_unique($out));
}
/** Lowercase blob for keyword matching. */
function report_search_blob(array $row, array $payload): string {
$java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
$native = is_array($payload['native'] ?? null) ? $payload['native'] : [];
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
$build = is_array($payload['build'] ?? null) ? $payload['build'] : [];
$parts = [
$row['crash_type'] ?? '',
$row['fingerprint'] ?? '',
$row['device_model'] ?? '',
$row['app_version'] ?? '',
$payload['scenario'] ?? '',
$java['exception'] ?? '',
$java['message'] ?? '',
$java['thread'] ?? '',
$native['signal'] ?? '',
$device['manufacturer'] ?? '',
$device['brand'] ?? '',
$device['model'] ?? '',
$device['product'] ?? '',
$app['package'] ?? '',
$app['version_name'] ?? '',
$build['git_commit'] ?? '',
$build['git'] ?? '',
];
if (!empty($java['stack_frames']) && is_array($java['stack_frames'])) {
$parts[] = implode(' ', array_map('strval', array_slice($java['stack_frames'], 0, 3)));
}
return mb_strtolower(implode(' ', array_filter(array_map('strval', $parts))));
}
/**
* @param list<string> $tokens
*/
function report_search_score(array $tokens, array $row, array $payload): int {
if ($tokens === []) {
return 0;
}
$blob = report_search_blob($row, $payload);
$java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
$exc = mb_strtolower((string) ($java['exception'] ?? ''));
$msg = mb_strtolower((string) ($java['message'] ?? ''));
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$mfr = mb_strtolower((string) ($device['manufacturer'] ?? ''));
$brand = mb_strtolower((string) ($device['brand'] ?? ''));
$model = mb_strtolower((string) ($device['model'] ?? ''));
$score = 0;
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if ($exc !== '' && str_contains($exc, $token)) {
$score += 220;
} elseif ($msg !== '' && str_contains($msg, $token)) {
$score += 140;
} elseif ($mfr !== '' && str_contains($mfr, $token)) {
$score += 70;
} elseif ($brand !== '' && str_contains($brand, $token)) {
$score += 65;
} elseif ($model !== '' && str_contains($model, $token)) {
$score += 60;
} elseif (str_contains($blob, $token)) {
$score += 45;
}
}
$rating = (int) ($row['rating'] ?? 0);
if ($rating > 0 && $score > 0) {
$score += $rating * 12;
}
$fpCnt = (int) ($row['fingerprint_cnt'] ?? 1);
if ($fpCnt > 1 && $score > 0) {
$score += min(40, (int) log($fpCnt + 1) * 10);
}
return $score;
}
/** System tags — not stored in tags_json (computed in UI). */
function report_reserved_tag_ids(): array {
return ['new', 'java', 'ndk', 'android'];
}
function normalize_tag_color(string $bg): string {
$bg = trim($bg);
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $bg) !== 1) {
return '#5c6b82';
}
if (strlen($bg) === 4) {
return '#' . $bg[1] . $bg[1] . $bg[2] . $bg[2] . $bg[3] . $bg[3];
}
return strtolower($bg);
}
/**
* @param list<mixed> $tags
* @return list<array{id:string,label:string,bg:string}>
*/
function normalize_report_tags(array $tags): array {
$reserved = array_flip(report_reserved_tag_ids());
$out = [];
$seen = [];
foreach ($tags as $tag) {
if (!is_array($tag)) {
continue;
}
$label = trim((string) ($tag['label'] ?? $tag['id'] ?? ''));
if ($label === '' || strlen($label) > 40) {
continue;
}
$id = trim((string) ($tag['id'] ?? $label));
$id = strtolower(preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? $id);
$id = trim($id, '-');
if ($id === '' || isset($reserved[$id])) {
continue;
}
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$out[] = [
'id' => $id,
'label' => $label,
'bg' => normalize_tag_color((string) ($tag['bg'] ?? '#5c6b82')),
];
if (count($out) >= 24) {
break;
}
}
return $out;
}
/** @return list<array{id:string,label:string,bg:string}> */
/**
* @return list<string>
*/
function request_tag_filter_ids(): array {
$raw = [];
if (isset($_GET['tag']) && is_array($_GET['tag'])) {
$raw = $_GET['tag'];
} elseif (isset($_GET['tag']) && is_string($_GET['tag']) && $_GET['tag'] !== '') {
$raw = [$_GET['tag']];
}
return TagCatalog::normalizeFilterIds($raw);
}
function request_tag_filter_mode(): string {
$mode = strtolower(trim((string) ($_GET['tag_mode'] ?? 'and')));
return $mode === 'or' ? 'or' : 'and';
}
/** @return list<array{id:string,label:string,bg:string}> */
function parse_report_tags(?string $json): array {
if ($json === null || $json === '') {
return [];
}
$raw = json_decode($json, true);
if (!is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $tag) {
if (!is_array($tag)) {
continue;
}
$id = (string) ($tag['id'] ?? $tag['label'] ?? '');
if ($id === '') {
continue;
}
$out[] = [
'id' => $id,
'label' => (string) ($tag['label'] ?? $id),
'bg' => (string) ($tag['bg'] ?? '#5c6b82'),
];
}
return $out;
}
function log_crash_event(string $channel, string $message): void {
$dir = dirname(__DIR__) . '/storage';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$line = date('c') . " [$channel] $message\n";
@file_put_contents($dir . '/crash.log', $line, FILE_APPEND | LOCK_EX);
}