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:
26
sim/cluster0/lab-seeds/backend/src/AnalyticsHead.php
Normal file
26
sim/cluster0/lab-seeds/backend/src/AnalyticsHead.php
Normal 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";
|
||||
}
|
||||
}
|
||||
175
sim/cluster0/lab-seeds/backend/src/Auth.php
Normal file
175
sim/cluster0/lab-seeds/backend/src/Auth.php
Normal 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;
|
||||
}
|
||||
}
|
||||
108
sim/cluster0/lab-seeds/backend/src/AuthAttempts.php
Normal file
108
sim/cluster0/lab-seeds/backend/src/AuthAttempts.php
Normal 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;
|
||||
}
|
||||
}
|
||||
68
sim/cluster0/lab-seeds/backend/src/AuthCrypto.php
Normal file
68
sim/cluster0/lab-seeds/backend/src/AuthCrypto.php
Normal 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);
|
||||
}
|
||||
}
|
||||
140
sim/cluster0/lab-seeds/backend/src/AuthEmailSchema.php
Normal file
140
sim/cluster0/lab-seeds/backend/src/AuthEmailSchema.php
Normal 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'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
56
sim/cluster0/lab-seeds/backend/src/AuthFactors.php
Normal file
56
sim/cluster0/lab-seeds/backend/src/AuthFactors.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
142
sim/cluster0/lab-seeds/backend/src/AuthMailer.php
Normal file
142
sim/cluster0/lab-seeds/backend/src/AuthMailer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
102
sim/cluster0/lab-seeds/backend/src/AuthRegistration.php
Normal file
102
sim/cluster0/lab-seeds/backend/src/AuthRegistration.php
Normal 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));
|
||||
}
|
||||
}
|
||||
93
sim/cluster0/lab-seeds/backend/src/AuthTotp.php
Normal file
93
sim/cluster0/lab-seeds/backend/src/AuthTotp.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
sim/cluster0/lab-seeds/backend/src/AuthTwoFactorPage.php
Normal file
35
sim/cluster0/lab-seeds/backend/src/AuthTwoFactorPage.php
Normal 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'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
669
sim/cluster0/lab-seeds/backend/src/Database.php
Normal file
669
sim/cluster0/lab-seeds/backend/src/Database.php
Normal 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;
|
||||
}
|
||||
}
|
||||
103
sim/cluster0/lab-seeds/backend/src/DeviceRepository.php
Normal file
103
sim/cluster0/lab-seeds/backend/src/DeviceRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
600
sim/cluster0/lab-seeds/backend/src/GraphRepository.php
Normal file
600
sim/cluster0/lab-seeds/backend/src/GraphRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
607
sim/cluster0/lab-seeds/backend/src/LiveCastRepository.php
Normal file
607
sim/cluster0/lab-seeds/backend/src/LiveCastRepository.php
Normal file
@@ -0,0 +1,607 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LiveCastRepository {
|
||||
private static bool $schemaEnsured = false;
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
Database::withMigrationLock($pdo, 'live_cast_sessions', static function () use ($pdo): void {
|
||||
self::createTables($pdo);
|
||||
self::ensureIndexes($pdo);
|
||||
});
|
||||
self::$schemaEnsured = true;
|
||||
}
|
||||
|
||||
private static function createTables(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'live_cast_sessions')) {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
owner_user_id INT UNSIGNED NULL,
|
||||
owner_username VARCHAR(64) NULL,
|
||||
device_id VARCHAR(128) NULL,
|
||||
platform VARCHAR(24) NOT NULL DEFAULT 'android',
|
||||
status ENUM('intent','live','closed','expired') NOT NULL DEFAULT 'intent',
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
last_heartbeat_ms BIGINT NOT NULL,
|
||||
ended_at_ms BIGINT NULL,
|
||||
max_duration_s INT NOT NULL DEFAULT 300,
|
||||
join_short_url TEXT NULL,
|
||||
join_qr_url TEXT NULL,
|
||||
join_slug VARCHAR(32) NULL,
|
||||
direct_short_url TEXT NULL,
|
||||
direct_qr_url TEXT NULL,
|
||||
direct_slug VARCHAR(32) NULL,
|
||||
codec_video VARCHAR(32) NULL,
|
||||
codec_audio VARCHAR(32) NULL,
|
||||
bw_mode VARCHAR(24) NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_live_cast_session_id (session_id),
|
||||
KEY idx_live_cast_company_status (company_id, status),
|
||||
KEY idx_live_cast_last_hb (last_heartbeat_ms),
|
||||
KEY idx_live_cast_owner (owner_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
owner_user_id INTEGER NULL,
|
||||
owner_username TEXT NULL,
|
||||
device_id TEXT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'android',
|
||||
status TEXT NOT NULL DEFAULT 'intent',
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
last_heartbeat_ms INTEGER NOT NULL,
|
||||
ended_at_ms INTEGER NULL,
|
||||
max_duration_s INTEGER NOT NULL DEFAULT 300,
|
||||
join_short_url TEXT NULL,
|
||||
join_qr_url TEXT NULL,
|
||||
join_slug TEXT NULL,
|
||||
direct_short_url TEXT NULL,
|
||||
direct_qr_url TEXT NULL,
|
||||
direct_slug TEXT NULL,
|
||||
codec_video TEXT NULL,
|
||||
codec_audio TEXT NULL,
|
||||
bw_mode TEXT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Database::tableExists($pdo, 'live_cast_join_events')) {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_join_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
event_type ENUM('open','join','leave') NOT NULL DEFAULT 'open',
|
||||
platform VARCHAR(24) NOT NULL DEFAULT 'unknown',
|
||||
channel VARCHAR(24) NOT NULL DEFAULT 'short_link',
|
||||
direct TINYINT(1) NOT NULL DEFAULT 0,
|
||||
user_id INT UNSIGNED NULL,
|
||||
anon_id VARCHAR(96) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
ip_hash CHAR(64) NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_live_join_session_time (session_id, created_at_ms),
|
||||
KEY idx_live_join_company_time (company_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_join_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
event_type TEXT NOT NULL DEFAULT 'open',
|
||||
platform TEXT NOT NULL DEFAULT 'unknown',
|
||||
channel TEXT NOT NULL DEFAULT 'short_link',
|
||||
direct INTEGER NOT NULL DEFAULT 0,
|
||||
user_id INTEGER NULL,
|
||||
anon_id TEXT NULL,
|
||||
user_agent TEXT NULL,
|
||||
ip_hash TEXT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureIndexes(PDO $pdo): void {
|
||||
if (Database::isMysql()) {
|
||||
return;
|
||||
}
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_company_status ON live_cast_sessions (company_id, status)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_last_hb ON live_cast_sessions (last_heartbeat_ms)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_session_time ON live_cast_join_events (session_id, created_at_ms)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_company_time ON live_cast_join_events (company_id, created_at_ms)');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function createIntent(array $input, array $actor): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$userId = (int) ($actor['id'] ?? 0);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$ownerUsername = trim((string) ($actor['username'] ?? ''));
|
||||
$platform = strtolower(trim((string) ($input['platform'] ?? 'android')));
|
||||
if ($platform === '') {
|
||||
$platform = 'android';
|
||||
}
|
||||
$deviceId = trim((string) ($input['device_id'] ?? ''));
|
||||
$maxDuration = (int) ($input['max_duration_s'] ?? 300);
|
||||
$maxDuration = max(60, min(1800, $maxDuration));
|
||||
if (str_starts_with($platform, 'education')) {
|
||||
$maxDuration = min($maxDuration, 300);
|
||||
}
|
||||
|
||||
$sessionId = self::newSessionId($now, $companyId, $userId);
|
||||
$joinLongUrl = self::joinLongUrl($sessionId, false);
|
||||
$directLongUrl = self::joinLongUrl($sessionId, true);
|
||||
|
||||
$links = self::makeShortLinks($joinLongUrl, $directLongUrl);
|
||||
$meta = is_array($input['meta'] ?? null) ? $input['meta'] : [];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO live_cast_sessions (
|
||||
session_id, company_id, owner_user_id, owner_username, device_id, platform, status,
|
||||
started_at_ms, last_heartbeat_ms, max_duration_s,
|
||||
join_short_url, join_qr_url, join_slug, direct_short_url, direct_qr_url, direct_slug,
|
||||
codec_video, codec_audio, bw_mode, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$userId > 0 ? $userId : null,
|
||||
$ownerUsername !== '' ? $ownerUsername : null,
|
||||
$deviceId !== '' ? $deviceId : null,
|
||||
$platform,
|
||||
'intent',
|
||||
$now,
|
||||
$now,
|
||||
$maxDuration,
|
||||
$links['join_short_url'],
|
||||
$links['join_qr_url'],
|
||||
$links['join_slug'],
|
||||
$links['direct_short_url'],
|
||||
$links['direct_qr_url'],
|
||||
$links['direct_slug'],
|
||||
trim((string) ($input['codec_video'] ?? '')) ?: null,
|
||||
trim((string) ($input['codec_audio'] ?? '')) ?: null,
|
||||
trim((string) ($input['bw_mode'] ?? '')) ?: null,
|
||||
safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
return self::sessionById($sessionId) ?? [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function heartbeat(string $sessionId, array $input, array $actor): ?array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return null;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$status = strtolower(trim((string) ($input['status'] ?? 'active')));
|
||||
if ($status !== 'ended' && $status !== 'active') {
|
||||
$status = 'active';
|
||||
}
|
||||
$nextState = $status === 'ended' ? 'closed' : 'live';
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE live_cast_sessions
|
||||
SET status = ?, last_heartbeat_ms = ?, ended_at_ms = CASE WHEN ? = ? THEN ? ELSE ended_at_ms END,
|
||||
codec_video = COALESCE(NULLIF(?, \'\'), codec_video),
|
||||
codec_audio = COALESCE(NULLIF(?, \'\'), codec_audio),
|
||||
bw_mode = COALESCE(NULLIF(?, \'\'), bw_mode)
|
||||
WHERE session_id = ? AND company_id = ?'
|
||||
);
|
||||
$stmt->execute([
|
||||
$nextState,
|
||||
$now,
|
||||
$nextState,
|
||||
'closed',
|
||||
$now,
|
||||
trim((string) ($input['codec_video'] ?? '')),
|
||||
trim((string) ($input['codec_audio'] ?? '')),
|
||||
trim((string) ($input['bw_mode'] ?? '')),
|
||||
$sessionId,
|
||||
$companyId,
|
||||
]);
|
||||
if ($stmt->rowCount() < 1) {
|
||||
return null;
|
||||
}
|
||||
if (isset($input['stats']) && is_array($input['stats'])) {
|
||||
$row = self::sessionById($sessionId);
|
||||
$meta = [];
|
||||
if ($row !== null && !empty($row['metadata_json'])) {
|
||||
$decoded = json_decode((string) $row['metadata_json'], true);
|
||||
if (is_array($decoded)) {
|
||||
$meta = $decoded;
|
||||
}
|
||||
}
|
||||
$meta['last_stats'] = $input['stats'];
|
||||
$meta['last_stats_ms'] = $now;
|
||||
$upd = $pdo->prepare('UPDATE live_cast_sessions SET metadata_json = ? WHERE session_id = ?');
|
||||
$upd->execute([
|
||||
safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
$sessionId,
|
||||
]);
|
||||
}
|
||||
return self::sessionById($sessionId);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function activeSessions(array $actor, ?string $platform = null): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$pdo = Database::pdo();
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$where = ['company_id = ?', "status IN ('intent','live')"];
|
||||
$params = [$companyId];
|
||||
if ($platform !== null && trim($platform) !== '') {
|
||||
$where[] = 'platform = ?';
|
||||
$params[] = strtolower(trim($platform));
|
||||
}
|
||||
$sql = 'SELECT session_id, owner_user_id, owner_username, device_id, platform, status,
|
||||
started_at_ms, last_heartbeat_ms, max_duration_s, ended_at_ms,
|
||||
join_short_url, join_qr_url, direct_short_url, direct_qr_url,
|
||||
codec_video, codec_audio, bw_mode
|
||||
FROM live_cast_sessions
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY last_heartbeat_ms DESC LIMIT 200';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = self::normalizeSessionRow($row);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function analytics(int $days, array $actor): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$days = max(1, min(90, $days));
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$pdo = Database::pdo();
|
||||
|
||||
$castsPerDay = self::countByDay($pdo, 'live_cast_sessions', 'started_at_ms', $startMs, $companyId);
|
||||
$avgDurationPerDay = self::avgDurationByDay($pdo, $startMs, $companyId);
|
||||
$platformMix = self::breakdown($pdo, 'live_cast_sessions', 'platform', $startMs, $companyId);
|
||||
$videoCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_video', $startMs, $companyId);
|
||||
$audioCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_audio', $startMs, $companyId);
|
||||
$bwModeMix = self::breakdown($pdo, 'live_cast_sessions', 'bw_mode', $startMs, $companyId);
|
||||
$joinsPerPlatform = self::breakdown($pdo, 'live_cast_join_events', 'platform', $startMs, $companyId);
|
||||
|
||||
$topUsersStmt = $pdo->prepare(
|
||||
'SELECT COALESCE(NULLIF(owner_username, \'\'), \'unknown\') AS u, COUNT(*) AS c
|
||||
FROM live_cast_sessions
|
||||
WHERE company_id = ? AND started_at_ms >= ?
|
||||
GROUP BY u ORDER BY c DESC LIMIT 20'
|
||||
);
|
||||
$topUsersStmt->execute([$companyId, $startMs]);
|
||||
$topUsers = [];
|
||||
foreach ($topUsersStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$topUsers[] = ['label' => (string) ($row['u'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
|
||||
return [
|
||||
'window_days' => $days,
|
||||
'casts_per_day' => $castsPerDay,
|
||||
'avg_length_s_per_day' => $avgDurationPerDay,
|
||||
'platform_mix' => $platformMix,
|
||||
'joins_platform_mix' => $joinsPerPlatform,
|
||||
'video_codec_mix' => $videoCodecMix,
|
||||
'audio_codec_mix' => $audioCodecMix,
|
||||
'bw_mode_mix' => $bwModeMix,
|
||||
'top_users' => $topUsers,
|
||||
];
|
||||
}
|
||||
|
||||
public static function registerJoin(string $sessionId, array $payload, array $actor): bool {
|
||||
self::ensureSchema();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return false;
|
||||
}
|
||||
$session = self::sessionById($sessionId);
|
||||
if ($session === null) {
|
||||
return false;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$companyId = (int) ($session['company_id'] ?? Rbac::defaultCompanyId());
|
||||
$eventType = strtolower(trim((string) ($payload['event_type'] ?? 'open')));
|
||||
if (!in_array($eventType, ['open', 'join', 'leave'], true)) {
|
||||
$eventType = 'open';
|
||||
}
|
||||
$platform = strtolower(trim((string) ($payload['platform'] ?? 'unknown')));
|
||||
if ($platform === '') {
|
||||
$platform = 'unknown';
|
||||
}
|
||||
$channel = strtolower(trim((string) ($payload['channel'] ?? 'short_link')));
|
||||
if ($channel === '') {
|
||||
$channel = 'short_link';
|
||||
}
|
||||
$direct = !empty($payload['direct']) ? 1 : 0;
|
||||
$userId = (int) ($actor['id'] ?? 0);
|
||||
$anon = trim((string) ($payload['anon_id'] ?? ''));
|
||||
$ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$ip = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$ipHash = $ip !== '' ? hash('sha256', $ip . '|ac-live-cast') : null;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO live_cast_join_events
|
||||
(session_id, company_id, event_type, platform, channel, direct, user_id, anon_id, user_agent, ip_hash, created_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$eventType,
|
||||
$platform,
|
||||
$channel,
|
||||
$direct,
|
||||
$userId > 0 ? $userId : null,
|
||||
$anon !== '' ? $anon : null,
|
||||
$ua !== '' ? substr($ua, 0, 255) : null,
|
||||
$ipHash,
|
||||
$now,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function sessionById(string $sessionId): ?array {
|
||||
self::ensureSchema();
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT * FROM live_cast_sessions WHERE session_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$sessionId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
return self::normalizeSessionRow($row);
|
||||
}
|
||||
|
||||
private static function expireStale(): void {
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$staleBefore = $now - 60_000;
|
||||
$sql = "UPDATE live_cast_sessions
|
||||
SET status = 'expired', ended_at_ms = COALESCE(ended_at_ms, ?)
|
||||
WHERE status IN ('intent','live')
|
||||
AND last_heartbeat_ms < ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$now, $staleBefore]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function normalizeSessionRow(array $row): array {
|
||||
return [
|
||||
'session_id' => (string) ($row['session_id'] ?? ''),
|
||||
'company_id' => (int) ($row['company_id'] ?? 0),
|
||||
'owner_user_id' => isset($row['owner_user_id']) ? (int) $row['owner_user_id'] : null,
|
||||
'owner_username' => (string) ($row['owner_username'] ?? ''),
|
||||
'device_id' => (string) ($row['device_id'] ?? ''),
|
||||
'platform' => (string) ($row['platform'] ?? ''),
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
'started_at_ms' => (int) ($row['started_at_ms'] ?? 0),
|
||||
'last_heartbeat_ms' => (int) ($row['last_heartbeat_ms'] ?? 0),
|
||||
'ended_at_ms' => isset($row['ended_at_ms']) ? (int) $row['ended_at_ms'] : null,
|
||||
'max_duration_s' => (int) ($row['max_duration_s'] ?? 0),
|
||||
'join_short_url' => (string) ($row['join_short_url'] ?? ''),
|
||||
'join_qr_url' => (string) ($row['join_qr_url'] ?? ''),
|
||||
'direct_short_url' => (string) ($row['direct_short_url'] ?? ''),
|
||||
'direct_qr_url' => (string) ($row['direct_qr_url'] ?? ''),
|
||||
'codec_video' => (string) ($row['codec_video'] ?? ''),
|
||||
'codec_audio' => (string) ($row['codec_audio'] ?? ''),
|
||||
'bw_mode' => (string) ($row['bw_mode'] ?? ''),
|
||||
'metadata_json' => (string) ($row['metadata_json'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function newSessionId(int $nowMs, int $companyId, int $userId): string {
|
||||
$seed = $nowMs . ':' . $companyId . ':' . $userId . ':' . bin2hex(random_bytes(6));
|
||||
return 'live-' . substr(hash('sha256', $seed), 0, 20);
|
||||
}
|
||||
|
||||
private static function joinLongUrl(string $sessionId, bool $direct): string {
|
||||
$base = rtrim((string) cfg('base_path', '/app/androidcast_project/issues'), '/');
|
||||
if (str_ends_with($base, '/crashes')) {
|
||||
$base = substr($base, 0, -strlen('/crashes')) . '/issues';
|
||||
}
|
||||
$qs = 'session_id=' . rawurlencode($sessionId);
|
||||
if ($direct) {
|
||||
$qs .= '&direct=1';
|
||||
}
|
||||
return 'https://apps.f0xx.org' . $base . '/live/join?' . $qs;
|
||||
}
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
private static function makeShortLinks(string $joinLongUrl, string $directLongUrl): array {
|
||||
$out = [
|
||||
'join_short_url' => null,
|
||||
'join_qr_url' => null,
|
||||
'join_slug' => null,
|
||||
'direct_short_url' => null,
|
||||
'direct_qr_url' => null,
|
||||
'direct_slug' => null,
|
||||
];
|
||||
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
||||
return $out;
|
||||
}
|
||||
$bearers = ShortLinksRepository::listBearers();
|
||||
if ($bearers === []) {
|
||||
return $out;
|
||||
}
|
||||
$bearerId = (int) ($bearers[0]['id'] ?? 0);
|
||||
if ($bearerId <= 0) {
|
||||
return $out;
|
||||
}
|
||||
$join = ShortLinksRepository::createLink($bearerId, $joinLongUrl, 3600);
|
||||
if (!empty($join['ok'])) {
|
||||
$out['join_short_url'] = (string) ($join['short_url'] ?? '');
|
||||
$out['join_qr_url'] = (string) ($join['qr_url'] ?? '');
|
||||
$out['join_slug'] = (string) ($join['slug'] ?? '');
|
||||
}
|
||||
$direct = ShortLinksRepository::createLink($bearerId, $directLongUrl, 3600);
|
||||
if (!empty($direct['ok'])) {
|
||||
$out['direct_short_url'] = (string) ($direct['short_url'] ?? '');
|
||||
$out['direct_qr_url'] = (string) ($direct['qr_url'] ?? '');
|
||||
$out['direct_slug'] = (string) ($direct['slug'] ?? '');
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array{x:string,y:int}> */
|
||||
private static function countByDay(PDO $pdo, string $table, string $msColumn, int $startMs, int $companyId): array {
|
||||
$day = Database::isMysql()
|
||||
? 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))'
|
||||
: "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')";
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . '
|
||||
WHERE company_id = ? AND ' . $msColumn . ' >= ?
|
||||
GROUP BY d ORDER BY d'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array{x:string,y:float}> */
|
||||
private static function avgDurationByDay(PDO $pdo, int $startMs, int $companyId): array {
|
||||
$day = Database::isMysql()
|
||||
? 'DATE(FROM_UNIXTIME(FLOOR(started_at_ms / 1000)))'
|
||||
: "strftime('%Y-%m-%d', started_at_ms / 1000, 'unixepoch')";
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT ' . $day . ' AS d,
|
||||
AVG(CASE WHEN ended_at_ms IS NOT NULL AND ended_at_ms >= started_at_ms
|
||||
THEN (ended_at_ms - started_at_ms) / 1000.0 ELSE 0 END) AS v
|
||||
FROM live_cast_sessions
|
||||
WHERE company_id = ? AND started_at_ms >= ?
|
||||
GROUP BY d ORDER BY d'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => round((float) ($row['v'] ?? 0), 1)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function listSessions(int $days, array $actor, int $limit = 200): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$days = max(1, min(90, $days));
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$limit = max(1, min(500, $limit));
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT s.session_id, s.owner_user_id, s.owner_username, s.device_id, s.platform, s.status,
|
||||
s.started_at_ms, s.last_heartbeat_ms, s.ended_at_ms, s.max_duration_s,
|
||||
s.join_short_url, s.direct_short_url, s.codec_video, s.codec_audio, s.bw_mode,
|
||||
(SELECT COUNT(*) FROM live_cast_join_events j
|
||||
WHERE j.session_id = s.session_id AND j.event_type IN (\'open\', \'join\')) AS join_opens
|
||||
FROM live_cast_sessions s
|
||||
WHERE s.company_id = ? AND s.started_at_ms >= ?
|
||||
ORDER BY s.last_heartbeat_ms DESC
|
||||
LIMIT ' . (int) $limit
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$normalized = self::normalizeSessionRow($row);
|
||||
$normalized['join_opens'] = (int) ($row['join_opens'] ?? 0);
|
||||
$out[] = $normalized;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function publicSession(string $sessionId): ?array {
|
||||
$row = self::sessionById($sessionId);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'session_id' => $row['session_id'],
|
||||
'owner_username' => $row['owner_username'],
|
||||
'platform' => $row['platform'],
|
||||
'status' => $row['status'],
|
||||
'started_at_ms' => $row['started_at_ms'],
|
||||
'last_heartbeat_ms' => $row['last_heartbeat_ms'],
|
||||
'ended_at_ms' => $row['ended_at_ms'],
|
||||
'max_duration_s' => $row['max_duration_s'],
|
||||
'codec_video' => $row['codec_video'],
|
||||
'codec_audio' => $row['codec_audio'],
|
||||
'bw_mode' => $row['bw_mode'],
|
||||
'join_short_url' => $row['join_short_url'] ?? '',
|
||||
'join_qr_url' => $row['join_qr_url'] ?? '',
|
||||
'signaling_api' => Auth::basePath() . '/api/live_cast_signaling.php',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array{label:string,value:int}> */
|
||||
private static function breakdown(PDO $pdo, string $table, string $column, int $startMs, int $companyId): array {
|
||||
$allowedTables = ['live_cast_sessions', 'live_cast_join_events'];
|
||||
if (!in_array($table, $allowedTables, true)) {
|
||||
return [];
|
||||
}
|
||||
$allowedCols = ['platform', 'codec_video', 'codec_audio', 'bw_mode'];
|
||||
if ($table === 'live_cast_join_events') {
|
||||
$allowedCols = ['platform'];
|
||||
}
|
||||
if (!in_array($column, $allowedCols, true)) {
|
||||
return [];
|
||||
}
|
||||
$timeColumn = $table === 'live_cast_sessions' ? 'started_at_ms' : 'created_at_ms';
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COALESCE(NULLIF(' . $column . ', \'\'), \'unknown\') AS k, COUNT(*) AS c
|
||||
FROM ' . $table . '
|
||||
WHERE company_id = ? AND ' . $timeColumn . ' >= ?
|
||||
GROUP BY k ORDER BY c DESC'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
401
sim/cluster0/lab-seeds/backend/src/Rbac.php
Normal file
401
sim/cluster0/lab-seeds/backend/src/Rbac.php
Normal 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);
|
||||
}
|
||||
}
|
||||
252
sim/cluster0/lab-seeds/backend/src/RbacAdminRepository.php
Normal file
252
sim/cluster0/lab-seeds/backend/src/RbacAdminRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1423
sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php
Normal file
1423
sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
707
sim/cluster0/lab-seeds/backend/src/ReportRepository.php
Normal file
707
sim/cluster0/lab-seeds/backend/src/ReportRepository.php
Normal file
@@ -0,0 +1,707 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ReportRepository {
|
||||
private const LIST_SORT = [
|
||||
'priority',
|
||||
'generated_at_ms',
|
||||
'received_at_ms',
|
||||
'crash_type',
|
||||
'device_model',
|
||||
'app_version',
|
||||
'fingerprint',
|
||||
'fingerprint_cnt',
|
||||
'rating',
|
||||
];
|
||||
private const GROUP_SORT = [
|
||||
'cnt',
|
||||
'fingerprint',
|
||||
'crash_type',
|
||||
'last_generated_ms',
|
||||
'last_received_ms',
|
||||
];
|
||||
|
||||
public static function insert(array $payload): void {
|
||||
$pdo = Database::pdo();
|
||||
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
|
||||
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('payload json_encode failed: ' . json_last_error_msg());
|
||||
}
|
||||
$tags = json_encode($payload['tags'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$companyId = Rbac::defaultCompanyId();
|
||||
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO reports (company_id, device_id, report_id, fingerprint, crash_type, generated_at_ms,
|
||||
received_at_ms, device_model, app_version, payload_json, tags_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$companyId,
|
||||
$deviceId > 0 ? $deviceId : null,
|
||||
$payload['report_id'] ?? uniqid('rpt_', true),
|
||||
$payload['fingerprint'] ?? 'unknown',
|
||||
$payload['crash_type'] ?? 'java',
|
||||
(int) ($payload['generated_at_epoch_ms'] ?? 0),
|
||||
(int) round(microtime(true) * 1000),
|
||||
$device['model'] ?? null,
|
||||
$app['version_name'] ?? null,
|
||||
$json,
|
||||
$tags ?: '[]',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function listReports(bool $grouped): array {
|
||||
return self::listPage($grouped, 1, 500, 'generated_at_ms', 'desc')['items'];
|
||||
}
|
||||
|
||||
public static function listPage(
|
||||
bool $grouped,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $sort,
|
||||
string $dir,
|
||||
int $sinceMs = 0,
|
||||
array $tagFilters = [],
|
||||
string $tagMode = 'and'
|
||||
): array {
|
||||
if ($tagFilters !== []) {
|
||||
return self::listFiltered(
|
||||
['tags' => $tagFilters, 'tag_mode' => $tagMode],
|
||||
$page,
|
||||
$perPage,
|
||||
$sort,
|
||||
$dir,
|
||||
$sinceMs
|
||||
);
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
|
||||
if ($grouped) {
|
||||
$sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt';
|
||||
$gWhere = ['1=1'];
|
||||
$gParams = [];
|
||||
self::appendCompanyScope('company_id', $gWhere, $gParams);
|
||||
$gWhereSql = implode(' AND ', $gWhere);
|
||||
$countStmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM reports WHERE $gWhereSql GROUP BY fingerprint, crash_type
|
||||
) t"
|
||||
);
|
||||
$countStmt->execute($gParams);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sql = "SELECT fingerprint, crash_type,
|
||||
MIN(device_model) AS device_model,
|
||||
MAX(app_version) AS app_version,
|
||||
COUNT(*) AS cnt,
|
||||
MAX(generated_at_ms) AS last_generated_ms,
|
||||
MAX(received_at_ms) AS last_received_ms
|
||||
FROM reports
|
||||
WHERE $gWhereSql
|
||||
GROUP BY fingerprint, crash_type
|
||||
ORDER BY $sortCol $dir
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($gParams);
|
||||
$items = $stmt->fetchAll();
|
||||
foreach ($items as &$row) {
|
||||
$row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0);
|
||||
$row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs
|
||||
? (int) $row['cnt'] : 0;
|
||||
$row['rating'] = report_rating_score((int) $row['cnt'], (int) $row['fingerprint_recent_cnt'], (int) $row['cnt']);
|
||||
$row['os_name'] = '';
|
||||
$row['os_version'] = '';
|
||||
$row['brief'] = report_brief_lines($row, true);
|
||||
}
|
||||
unset($row);
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => $sortCol,
|
||||
'dir' => strtolower($dir),
|
||||
'grouped' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
|
||||
if ($sortCol === 'rating') {
|
||||
$sortCol = 'fingerprint_cnt';
|
||||
}
|
||||
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
|
||||
$where = ['1=1'];
|
||||
$scopeParams = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $scopeParams);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
|
||||
$countStmt->execute($scopeParams);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY ' . $orderBy . '
|
||||
LIMIT ' . $perPage . ' OFFSET ' . $offset;
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $scopeParams));
|
||||
$items = $stmt->fetchAll();
|
||||
self::finishListRows($items, $sinceMs, false);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
|
||||
'dir' => strtolower($dir),
|
||||
'grouped' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{similar_to?:int,device?:string,abi?:string,os_sdk?:int,os_release?:string} $filters
|
||||
*/
|
||||
public static function listFiltered(
|
||||
array $filters,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $sort,
|
||||
string $dir,
|
||||
int $sinceMs = 0
|
||||
): array {
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$device = trim((string) ($filters['device'] ?? ''));
|
||||
if ($device !== '') {
|
||||
$like = '%' . $device . '%';
|
||||
$where[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
$abi = trim((string) ($filters['abi'] ?? ''));
|
||||
if ($abi !== '') {
|
||||
$where[] = 'r.payload_json LIKE ?';
|
||||
$params[] = '%' . $abi . '%';
|
||||
}
|
||||
$sdk = (int) ($filters['os_sdk'] ?? 0);
|
||||
if ($sdk > 0) {
|
||||
$where[] = 'r.payload_json LIKE ?';
|
||||
$params[] = '%"sdk_int":' . $sdk . '%';
|
||||
}
|
||||
$release = trim((string) ($filters['os_release'] ?? ''));
|
||||
if ($release !== '') {
|
||||
$where[] = '(r.payload_json LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = '%"release":"' . $release . '"%';
|
||||
$params[] = '%"release": "' . $release . '"%';
|
||||
}
|
||||
$appVersion = trim((string) ($filters['app_version'] ?? ''));
|
||||
if ($appVersion !== '') {
|
||||
$like = '%' . $appVersion . '%';
|
||||
$where[] = '(r.app_version = ? OR r.app_version LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = $appVersion;
|
||||
$params[] = $like;
|
||||
$params[] = '%"version_name":"' . $appVersion . '"%';
|
||||
}
|
||||
$build = trim((string) ($filters['build'] ?? ''));
|
||||
if ($build !== '') {
|
||||
$where[] = 'r.payload_json LIKE ?';
|
||||
$params[] = '%' . $build . '%';
|
||||
}
|
||||
self::appendTagFilters($where, $params, $filters);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
|
||||
$countStmt->execute($params);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
|
||||
if ($sortCol === 'rating') {
|
||||
$sortCol = 'fingerprint_cnt';
|
||||
}
|
||||
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY ' . $orderBy . '
|
||||
LIMIT ' . $perPage . ' OFFSET ' . $offset;
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$items = $stmt->fetchAll();
|
||||
self::finishListRows($items, $sinceMs, false);
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
|
||||
'dir' => strtolower($dir),
|
||||
'grouped' => false,
|
||||
'filter' => $filters,
|
||||
];
|
||||
}
|
||||
|
||||
public static function listSimilar(
|
||||
int $anchorId,
|
||||
int $page,
|
||||
int $perPage,
|
||||
int $sinceMs = 0
|
||||
): array {
|
||||
$anchor = self::getById($anchorId);
|
||||
if ($anchor === null) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => 1,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'similarity',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'similar_to' => $anchorId,
|
||||
];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$payload = is_array($anchor['payload'] ?? null) ? $anchor['payload'] : [];
|
||||
$keys = report_similarity_keys($payload);
|
||||
$fp = $keys['fingerprint'];
|
||||
$ct = $keys['crash_type'];
|
||||
$familyLike = $keys['device_family'] !== '' ? '%' . $keys['device_family'] . '%' : null;
|
||||
$excLike = $keys['exception'] !== '' ? '%' . $keys['exception'] . '%' : null;
|
||||
$sigLike = $keys['signal'] !== '' ? '%' . $keys['signal'] . '%' : null;
|
||||
|
||||
$where = ['r.id != ?'];
|
||||
$params = [$anchorId];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$or = [];
|
||||
if ($fp !== '') {
|
||||
$or[] = 'r.fingerprint = ?';
|
||||
$params[] = $fp;
|
||||
}
|
||||
if ($ct !== '') {
|
||||
$or[] = 'r.crash_type = ?';
|
||||
$params[] = $ct;
|
||||
}
|
||||
if ($familyLike !== null) {
|
||||
$or[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
|
||||
$params[] = $familyLike;
|
||||
$params[] = $familyLike;
|
||||
}
|
||||
if ($excLike !== null) {
|
||||
$or[] = 'r.payload_json LIKE ?';
|
||||
$params[] = $excLike;
|
||||
}
|
||||
if ($sigLike !== null) {
|
||||
$or[] = 'r.payload_json LIKE ?';
|
||||
$params[] = $sigLike;
|
||||
}
|
||||
if ($or === []) {
|
||||
$or[] = '1=1';
|
||||
}
|
||||
$where[] = '(' . implode(' OR ', $or) . ')';
|
||||
$whereSql = implode(' AND ', $where);
|
||||
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY r.received_at_ms DESC
|
||||
LIMIT 1200';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$rows = $stmt->fetchAll();
|
||||
$scored = [];
|
||||
foreach ($rows as $row) {
|
||||
$p = json_decode($row['payload_json'] ?? '', true);
|
||||
if (!is_array($p)) {
|
||||
$p = [];
|
||||
}
|
||||
$score = report_similarity_score($keys, $row, $p);
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
$row['similarity_score'] = $score;
|
||||
$row['similarity_tier'] = $score >= 1000 ? 'exact' : ($score >= 200 ? 'strong' : ($score >= 80 ? 'likely' : 'guess'));
|
||||
$scored[] = $row;
|
||||
}
|
||||
usort($scored, static function (array $a, array $b): int {
|
||||
$sa = (int) ($a['similarity_score'] ?? 0);
|
||||
$sb = (int) ($b['similarity_score'] ?? 0);
|
||||
if ($sa !== $sb) {
|
||||
return $sb <=> $sa;
|
||||
}
|
||||
return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
|
||||
});
|
||||
$total = count($scored);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$slice = array_values(array_slice($scored, $offset, $perPage));
|
||||
if ($page === 1) {
|
||||
$anchorRow = null;
|
||||
foreach ($scored as $row) {
|
||||
if ((int) $row['id'] === $anchorId) {
|
||||
$anchorRow = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($anchorRow === null) {
|
||||
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
|
||||
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
|
||||
$anchorRow = [
|
||||
'id' => $anchorId,
|
||||
'report_id' => $anchor['report_id'] ?? '',
|
||||
'fingerprint' => $anchor['fingerprint'] ?? ($payload['fingerprint'] ?? ''),
|
||||
'crash_type' => $anchor['crash_type'] ?? ($payload['crash_type'] ?? ''),
|
||||
'generated_at_ms' => (int) ($anchor['generated_at_ms'] ?? 0),
|
||||
'received_at_ms' => (int) ($anchor['received_at_ms'] ?? 0),
|
||||
'device_model' => $anchor['device_model'] ?? ($device['model'] ?? null),
|
||||
'app_version' => $anchor['app_version'] ?? ($app['version_name'] ?? null),
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
|
||||
'tags_json' => json_encode($anchor['tags'] ?? [], JSON_UNESCAPED_UNICODE) ?: '[]',
|
||||
'viewed' => !empty($anchor['viewed']) ? 1 : 0,
|
||||
'fingerprint_cnt' => 1,
|
||||
'fingerprint_recent_cnt' => 0,
|
||||
];
|
||||
}
|
||||
$anchorRow['similarity_score'] = 10000;
|
||||
$anchorRow['similarity_tier'] = 'anchor';
|
||||
$slice = array_values(array_filter(
|
||||
$slice,
|
||||
static fn (array $r): bool => (int) ($r['id'] ?? 0) !== $anchorId
|
||||
));
|
||||
array_unshift($slice, $anchorRow);
|
||||
if (count($slice) > $perPage) {
|
||||
$slice = array_slice($slice, 0, $perPage);
|
||||
}
|
||||
}
|
||||
self::finishListRows($slice, $sinceMs, false, true);
|
||||
return [
|
||||
'items' => $slice,
|
||||
'total' => $total + 1,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'similarity',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'similar_to' => $anchorId,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function searchSuggestions(string $q, int $limit = 10): array {
|
||||
$tokens = search_query_tokens($q);
|
||||
$prefix = $tokens[0] ?? mb_strtolower(trim($q));
|
||||
if (strlen($prefix) < 2) {
|
||||
return [];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('company_id', $where, $params);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT payload_json, device_model, app_version, crash_type FROM reports
|
||||
WHERE $whereSql ORDER BY received_at_ms DESC LIMIT 500"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$freq = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$payload = json_decode($row['payload_json'] ?? '', true);
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
$java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
|
||||
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
|
||||
$candidates = array_filter([
|
||||
$java['exception'] ?? null,
|
||||
$row['crash_type'] ?? null,
|
||||
$row['device_model'] ?? null,
|
||||
$device['manufacturer'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$row['app_version'] ?? null,
|
||||
]);
|
||||
foreach ($candidates as $term) {
|
||||
$term = trim((string) $term);
|
||||
if ($term === '' || strlen($term) < 3) {
|
||||
continue;
|
||||
}
|
||||
$low = mb_strtolower($term);
|
||||
if (!str_contains($low, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
$freq[$term] = ($freq[$term] ?? 0) + 1;
|
||||
}
|
||||
if (!empty($java['message'])) {
|
||||
if (preg_match_all('/[A-Za-z][A-Za-z0-9_]{3,}(?:Exception|Error)/', (string) $java['message'], $m)) {
|
||||
foreach ($m[0] as $term) {
|
||||
$low = mb_strtolower($term);
|
||||
if (str_contains($low, $prefix)) {
|
||||
$freq[$term] = ($freq[$term] ?? 0) + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
arsort($freq);
|
||||
return array_slice(array_keys($freq), 0, max(1, min(20, $limit)));
|
||||
}
|
||||
|
||||
public static function search(string $q, int $page, int $perPage, int $sinceMs = 0): array {
|
||||
$tokens = search_query_tokens($q);
|
||||
if ($tokens === []) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => 1,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'relevance',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'search' => $q,
|
||||
];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$orParts = [];
|
||||
foreach ($tokens as $token) {
|
||||
$like = '%' . $token . '%';
|
||||
$orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ?
|
||||
OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)';
|
||||
array_push($params, $like, $like, $like, $like, $like);
|
||||
}
|
||||
$where[] = '(' . implode(' OR ', $orParts) . ')';
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$sql = 'SELECT ' . self::listSelectColumns() . '
|
||||
FROM reports r
|
||||
' . self::fingerprintStatsJoin() . '
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE ' . $whereSql . '
|
||||
ORDER BY r.received_at_ms DESC
|
||||
LIMIT 1500';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$scored = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$payload = json_decode($row['payload_json'] ?? '', true);
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
report_enrich_list_row($row, $sinceMs);
|
||||
$score = report_search_score($tokens, $row, $payload);
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
$row['search_score'] = $score;
|
||||
$scored[] = $row;
|
||||
}
|
||||
usort($scored, static function (array $a, array $b): int {
|
||||
$sa = (int) ($a['search_score'] ?? 0);
|
||||
$sb = (int) ($b['search_score'] ?? 0);
|
||||
if ($sa !== $sb) {
|
||||
return $sb <=> $sa;
|
||||
}
|
||||
return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
|
||||
});
|
||||
$total = count($scored);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$slice = array_slice($scored, $offset, $perPage);
|
||||
self::finishListRows($slice, $sinceMs, false, false, true);
|
||||
return [
|
||||
'items' => $slice,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'sort' => 'relevance',
|
||||
'dir' => 'desc',
|
||||
'grouped' => false,
|
||||
'search' => $q,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private static function finishListRows(
|
||||
array &$items,
|
||||
int $sinceMs,
|
||||
bool $grouped,
|
||||
bool $similar = false,
|
||||
bool $search = false
|
||||
): void {
|
||||
foreach ($items as &$row) {
|
||||
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
||||
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
|
||||
report_enrich_list_row($row, $sinceMs);
|
||||
$brief = report_brief_lines($row, $grouped);
|
||||
if ($similar && !empty($row['similarity_tier'])) {
|
||||
$tier = (string) $row['similarity_tier'];
|
||||
$labels = [
|
||||
'anchor' => 'This report',
|
||||
'exact' => 'Same fingerprint',
|
||||
'strong' => 'Strong match',
|
||||
'likely' => 'Likely similar',
|
||||
'guess' => 'Possible match',
|
||||
];
|
||||
array_unshift($brief, ($labels[$tier] ?? $tier) . ' · score ' . (int) ($row['similarity_score'] ?? 0));
|
||||
}
|
||||
if ($search && !empty($row['search_score'])) {
|
||||
array_unshift($brief, 'Search match · relevance ' . (int) $row['search_score']);
|
||||
}
|
||||
$row['brief'] = $brief;
|
||||
unset($row['payload_json'], $row['tags_json']);
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
private static function buildListOrder(string $sort, string $dir): string {
|
||||
$dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC';
|
||||
$fpCnt = 'COALESCE(fp.fingerprint_cnt, 1)';
|
||||
if ($sort === 'priority') {
|
||||
return "$fpCnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC";
|
||||
}
|
||||
if ($sort === 'fingerprint_cnt') {
|
||||
return "$fpCnt $dir, r.received_at_ms DESC";
|
||||
}
|
||||
return "r.$sort $dir";
|
||||
}
|
||||
|
||||
private static function listSelectColumns(): string {
|
||||
return 'r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
|
||||
r.device_model, r.app_version, r.payload_json, r.tags_json,
|
||||
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
|
||||
COALESCE(fp.fingerprint_cnt, 1) AS fingerprint_cnt,
|
||||
COALESCE(fp.fingerprint_recent_cnt, 0) AS fingerprint_recent_cnt';
|
||||
}
|
||||
|
||||
/** One grouped scan for fingerprint counts (replaces per-row correlated subqueries). */
|
||||
private static function fingerprintStatsJoin(): string {
|
||||
return 'LEFT JOIN (
|
||||
SELECT company_id, fingerprint,
|
||||
COUNT(*) AS fingerprint_cnt,
|
||||
SUM(CASE WHEN received_at_ms > ? THEN 1 ELSE 0 END) AS fingerprint_recent_cnt
|
||||
FROM reports
|
||||
GROUP BY company_id, fingerprint
|
||||
) fp ON fp.company_id = r.company_id AND fp.fingerprint = r.fingerprint';
|
||||
}
|
||||
|
||||
public static function setCustomTags(int $reportId, array $tags): bool {
|
||||
if (self::getById($reportId) === null) {
|
||||
return false;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$normalized = normalize_report_tags($tags);
|
||||
$json = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$stmt = $pdo->prepare('UPDATE reports SET tags_json = ? WHERE id = ?');
|
||||
$stmt->execute([$json ?: '[]', $reportId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Distinct custom tags used across reports (for suggestions). */
|
||||
public static function listTagPresets(int $limit = 40): array {
|
||||
$pdo = Database::pdo();
|
||||
$where = ['tags_json IS NOT NULL', "tags_json != '[]'"];
|
||||
$params = [];
|
||||
self::appendCompanyScope('company_id', $where, $params);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$stmt = $pdo->prepare("SELECT tags_json FROM reports WHERE $whereSql LIMIT 500");
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) {
|
||||
$map[$tag['id']] = $tag;
|
||||
}
|
||||
}
|
||||
return array_values($map);
|
||||
}
|
||||
|
||||
public static function markViewed(int $reportId, int $userId): void {
|
||||
Database::upsertReportView(
|
||||
$userId,
|
||||
$reportId,
|
||||
(int) round(microtime(true) * 1000)
|
||||
);
|
||||
}
|
||||
|
||||
public static function getById(int $id): ?array {
|
||||
$pdo = Database::pdo();
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.*, CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed
|
||||
FROM reports r
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE r.id = ?'
|
||||
);
|
||||
$stmt->execute([$userId, $id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (!Rbac::canAccessReport($row)) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($row['payload_json'], true);
|
||||
$row['payload'] = is_array($decoded) ? $decoded : [];
|
||||
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
|
||||
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $where
|
||||
* @param list<mixed> $params
|
||||
*/
|
||||
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
|
||||
Rbac::appendCompanyScope($column, $where, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $where
|
||||
* @param list<mixed> $params
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private static function appendTagFilters(array &$where, array &$params, array $filters): void {
|
||||
$tags = $filters['tags'] ?? [];
|
||||
if (!is_array($tags) || $tags === []) {
|
||||
return;
|
||||
}
|
||||
$ids = TagCatalog::normalizeFilterIds($tags);
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
$mode = strtolower((string) ($filters['tag_mode'] ?? 'and')) === 'or' ? 'or' : 'and';
|
||||
$clauses = [];
|
||||
foreach ($ids as $id) {
|
||||
$clauses[] = '(r.tags_json LIKE ? OR r.tags_json LIKE ?)';
|
||||
$params[] = '%"id":"' . $id . '"%';
|
||||
$params[] = '%"id": "' . $id . '"%';
|
||||
}
|
||||
if ($mode === 'or') {
|
||||
$where[] = '(' . implode(' OR ', $clauses) . ')';
|
||||
} else {
|
||||
foreach ($clauses as $clause) {
|
||||
$where[] = $clause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
481
sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php
Normal file
481
sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
84
sim/cluster0/lab-seeds/backend/src/TagCatalog.php
Normal file
84
sim/cluster0/lab-seeds/backend/src/TagCatalog.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Workflow triage tags — multiple labels per report allowed (e.g. duplicate + fixed). */
|
||||
final class TagCatalog {
|
||||
/** @var list<array{id:string,label:string,bg:string}>|null */
|
||||
private static ?array $workflow = null;
|
||||
|
||||
/** @return list<array{id:string,label:string,bg:string}> */
|
||||
public static function workflowPresets(): array {
|
||||
if (self::$workflow !== null) {
|
||||
return self::$workflow;
|
||||
}
|
||||
self::$workflow = [
|
||||
['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3d8bfd'],
|
||||
['id' => 'fixed', 'label' => 'fixed', 'bg' => '#047857'],
|
||||
['id' => 'rejected', 'label' => 'rejected', 'bg' => '#dc2626'],
|
||||
['id' => 'done', 'label' => 'done', 'bg' => '#5c6b82'],
|
||||
['id' => 'not-reproducible', 'label' => 'not reproducible', 'bg' => '#d97706'],
|
||||
['id' => 'not-a-bug', 'label' => 'not a bug', 'bg' => '#6b7280'],
|
||||
['id' => 'duplicate', 'label' => 'duplicate', 'bg' => '#6d28d9'],
|
||||
];
|
||||
return self::$workflow;
|
||||
}
|
||||
|
||||
/** @return array<string, true> */
|
||||
public static function workflowIds(): array {
|
||||
$map = [];
|
||||
foreach (self::workflowPresets() as $tag) {
|
||||
$map[$tag['id']] = true;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
public static function isWorkflowId(string $id): bool {
|
||||
return isset(self::workflowIds()[$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $raw from query (?tag=fixed&tag=duplicate)
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function normalizeFilterIds(array $raw): array {
|
||||
$out = [];
|
||||
$seen = [];
|
||||
foreach ($raw as $item) {
|
||||
$id = strtolower(trim((string) $item));
|
||||
$id = preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? '';
|
||||
$id = trim($id, '-');
|
||||
if ($id === '' || strlen($id) > 32 || isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
if (isset(array_flip(report_reserved_tag_ids())[$id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$out[] = $id;
|
||||
if (count($out) >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow presets first, then distinct custom tags from DB (excluding workflow ids).
|
||||
*
|
||||
* @return list<array{id:string,label:string,bg:string}>
|
||||
*/
|
||||
public static function editorPresets(int $historicalLimit = 32): array {
|
||||
$map = [];
|
||||
foreach (self::workflowPresets() as $tag) {
|
||||
$map[$tag['id']] = $tag;
|
||||
}
|
||||
foreach (ReportRepository::listTagPresets($historicalLimit + 20) as $tag) {
|
||||
$id = (string) ($tag['id'] ?? '');
|
||||
if ($id === '' || isset($map[$id])) {
|
||||
continue;
|
||||
}
|
||||
$map[$id] = $tag;
|
||||
}
|
||||
return array_values($map);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
371
sim/cluster0/lab-seeds/backend/src/TicketRepository.php
Normal file
371
sim/cluster0/lab-seeds/backend/src/TicketRepository.php
Normal 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 : ''));
|
||||
}
|
||||
109
sim/cluster0/lab-seeds/backend/src/TicketTagCatalog.php
Normal file
109
sim/cluster0/lab-seeds/backend/src/TicketTagCatalog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
84
sim/cluster0/lab-seeds/backend/src/TicketWorkflow.php
Normal file
84
sim/cluster0/lab-seeds/backend/src/TicketWorkflow.php
Normal 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);
|
||||
}
|
||||
}
|
||||
133
sim/cluster0/lab-seeds/backend/src/UrlShortenerDatabase.php
Normal file
133
sim/cluster0/lab-seeds/backend/src/UrlShortenerDatabase.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
sim/cluster0/lab-seeds/backend/src/UserRepository.php
Normal file
44
sim/cluster0/lab-seeds/backend/src/UserRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
sim/cluster0/lab-seeds/backend/src/WireGuardAddressPool.php
Normal file
25
sim/cluster0/lab-seeds/backend/src/WireGuardAddressPool.php
Normal 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';
|
||||
}
|
||||
}
|
||||
142
sim/cluster0/lab-seeds/backend/src/WireGuardPeerProvisioner.php
Normal file
142
sim/cluster0/lab-seeds/backend/src/WireGuardPeerProvisioner.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
sim/cluster0/lab-seeds/backend/src/WireGuardTrafficStats.php
Normal file
73
sim/cluster0/lab-seeds/backend/src/WireGuardTrafficStats.php
Normal 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';
|
||||
}
|
||||
}
|
||||
618
sim/cluster0/lab-seeds/backend/src/bootstrap.php
Normal file
618
sim/cluster0/lab-seeds/backend/src/bootstrap.php
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user