mirror of
git://f0xx.org/ac/ac-platform-php
synced 2026-07-29 00:57:39 +03:00
initial
This commit is contained in:
5
README.md
Normal file
5
README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# ac-platform-php
|
||||
|
||||
Shared Database, bootstrap, session, footer.
|
||||
|
||||
Remote: `git://f0xx.org/ac/ac-platform-php`
|
||||
21
composer.json
Normal file
21
composer.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "androidcast/platform-php",
|
||||
"description": "AndroidCast shared PHP: Database, bootstrap, session",
|
||||
"type": "library",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"androidcast/platform-db": "dev-next"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git://f0xx.org/ac/ac-platform-db"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/",
|
||||
"platform/"
|
||||
]
|
||||
}
|
||||
}
|
||||
57
platform/README.md
Normal file
57
platform/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Shared platform pieces
|
||||
|
||||
## Footer (`footer.php`)
|
||||
|
||||
Flexible site-wide footer for hub, crashes, tickets, graphs, and builder consoles.
|
||||
|
||||
**Configure:** edit `footer.config.php` (copy from `footer.config.example.php`).
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ top (optional, full width) │
|
||||
├──────────────────────────┬──────────────────┤
|
||||
│ left (optional / auto) │ right (optional) │
|
||||
├──────────────────────────┴──────────────────┤
|
||||
│ bottom (optional, full width) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
When `left` is null, the left column is built from `copyright_symbol`, `holder`, and year settings.
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `top` | Full-width line above main row (`null` = hidden) |
|
||||
| `left` | Main left text; `null` = auto copyright line |
|
||||
| `right` | Main right text (e.g. build id, links) |
|
||||
| `bottom` | Full-width line below main row |
|
||||
| `holder` | Copyright name (default left) |
|
||||
| `copyright_symbol` | `©` (U+00A9) or empty |
|
||||
| `show_year` | Append year / range on default left |
|
||||
| `year` | Fixed end year, or `null` for current |
|
||||
| `year_start` | Start year (integer). With `year_through_current` → `2026 - current`; else range `2026–{end}` |
|
||||
| `year_through_current` | If true with `year_start`, year label is `{start} - current` (not a rolling end year) |
|
||||
| `use_i18n` | `data-i18n` on auto-built left column |
|
||||
| `footer_class` | Extra CSS class (always adds `platform-footer`) |
|
||||
|
||||
**Per-page override** when rendering:
|
||||
|
||||
```php
|
||||
platform_render_footer([
|
||||
'right' => 'OTA: staging',
|
||||
'bottom' => 'Internal only',
|
||||
]);
|
||||
```
|
||||
|
||||
**In PHP views:** `<?php platform_render_footer(); ?>` (loaded via each app `bootstrap.php`).
|
||||
|
||||
**Hub:** `examples/app_hub/index.php` (landing nginx runs it through PHP-FPM).
|
||||
|
||||
**Static HTML export:**
|
||||
|
||||
```bash
|
||||
php examples/platform/scripts/render-footer-html.php
|
||||
```
|
||||
|
||||
**Gitea** (`/git/`) uses the upstream Gitea UI; this footer does not apply there unless you customize Gitea templates separately.
|
||||
41
platform/footer.config.example.php
Normal file
41
platform/footer.config.example.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Site-wide footer (hub, crashes, tickets, graphs, builder).
|
||||
* Copy to footer.config.php and edit; footer.config.php is optional (defaults apply).
|
||||
*
|
||||
* Layout (empty/null row = hidden):
|
||||
* top — full-width line above main row
|
||||
* main — left | right (flex); invisible gap between columns
|
||||
* bottom — full-width line below main row
|
||||
*
|
||||
* Left column: set `left` to a custom string, or leave null to build from holder + year below.
|
||||
*/
|
||||
return [
|
||||
// --- Optional rows (static or PHP-dynamic strings) ---
|
||||
'top' => null,
|
||||
'left' => null,
|
||||
'right' => null,
|
||||
'bottom' => null,
|
||||
|
||||
// --- Default left column when `left` is null ---
|
||||
// Use holder_name + holder_url for a safe link (holder HTML is escaped if used alone).
|
||||
'holder_name' => 'Anton Afanaasyeu',
|
||||
'holder_url' => 'https://f0xx.org',
|
||||
'holder' => null,
|
||||
'copyright_symbol' => "\u{00A9}",
|
||||
'show_year' => false,
|
||||
// null = current calendar year at render time (end of range).
|
||||
'year' => null,
|
||||
// Integer start year. With year_through_current: "2026 - current" (literal suffix).
|
||||
// Without it: "2026–2027" range (en dash) or lone "2026" when start equals end year.
|
||||
'year_start' => 2026,
|
||||
'year_through_current' => true,
|
||||
|
||||
// CSS class on <footer> (platform-footer layout classes are always added).
|
||||
'footer_class' => 'bottom-bar',
|
||||
// When true, consoles with i18n.js replace default left text via data-i18n / data-year.
|
||||
'use_i18n' => true,
|
||||
'i18n_key' => 'footer.copyright',
|
||||
];
|
||||
4
platform/footer.config.php
Normal file
4
platform/footer.config.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return require __DIR__ . '/footer.config.example.php';
|
||||
241
platform/footer.php
Normal file
241
platform/footer.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Shared footer fragment for Android Cast web consoles and hub.
|
||||
*
|
||||
* Configure: examples/platform/footer.config.php (see footer.config.example.php).
|
||||
* Usage: <?php platform_render_footer(); ?> or platform_render_footer(['use_i18n' => false]);
|
||||
*
|
||||
* Layout (each row omitted when empty):
|
||||
* [ top — full width ]
|
||||
* [ left | right ] — main row (copyright defaults on left when left unset)
|
||||
* [ bottom — full width ]
|
||||
*/
|
||||
|
||||
function platform_footer_h(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function platform_footer_config(): array
|
||||
{
|
||||
static $loaded = null;
|
||||
if ($loaded !== null) {
|
||||
return $loaded;
|
||||
}
|
||||
$path = __DIR__ . '/footer.config.php';
|
||||
if (!is_file($path)) {
|
||||
$path = __DIR__ . '/footer.config.example.php';
|
||||
}
|
||||
$loaded = require $path;
|
||||
return is_array($loaded) ? $loaded : [];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $cfg */
|
||||
function platform_footer_year_end(array $cfg): int
|
||||
{
|
||||
if (array_key_exists('year', $cfg) && $cfg['year'] !== null) {
|
||||
return (int) $cfg['year'];
|
||||
}
|
||||
|
||||
return (int) date('Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* Year label: single year or range (e.g. 2026–2026) when year_start is set.
|
||||
*
|
||||
* @param array<string, mixed> $cfg
|
||||
*/
|
||||
function platform_footer_year_label(array $cfg): string
|
||||
{
|
||||
$end = platform_footer_year_end($cfg);
|
||||
$start = isset($cfg['year_start']) ? (int) $cfg['year_start'] : 0;
|
||||
if ($start > 0) {
|
||||
if (!empty($cfg['year_through_current'])) {
|
||||
return $start . ' - current';
|
||||
}
|
||||
if ($start === $end) {
|
||||
return (string) $start;
|
||||
}
|
||||
|
||||
return $start . "\u{2013}" . $end;
|
||||
}
|
||||
|
||||
return (string) $end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default left line: © holder[, year|year-range].
|
||||
*
|
||||
* @param array<string, mixed> $cfg
|
||||
*/
|
||||
function platform_footer_default_left(array $cfg): string
|
||||
{
|
||||
$symbol = trim((string) ($cfg['copyright_symbol'] ?? "\u{00A9}"));
|
||||
$holderName = trim((string) ($cfg['holder_name'] ?? ''));
|
||||
$holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
|
||||
$holder = trim((string) ($cfg['holder'] ?? ''));
|
||||
$showYear = (bool) ($cfg['show_year'] ?? true);
|
||||
$parts = [];
|
||||
if ($symbol !== '') {
|
||||
$parts[] = $symbol;
|
||||
}
|
||||
if ($holderName !== '') {
|
||||
$parts[] = $holderName;
|
||||
} elseif ($holder !== '') {
|
||||
$parts[] = $holder;
|
||||
}
|
||||
$line = implode(' ', $parts);
|
||||
if (!$showYear) {
|
||||
return $line;
|
||||
}
|
||||
$yearLabel = platform_footer_year_label($cfg);
|
||||
if ($line === '') {
|
||||
return $yearLabel;
|
||||
}
|
||||
|
||||
return $line . ', ' . $yearLabel;
|
||||
}
|
||||
|
||||
/** Render left footer HTML (holder link when configured). */
|
||||
function platform_footer_render_left_html(array $cfg, array $opts): ?string
|
||||
{
|
||||
$raw = platform_footer_resolve_left($cfg, $opts);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
$holderName = trim((string) ($cfg['holder_name'] ?? ''));
|
||||
$holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
|
||||
if (platform_footer_field($cfg, $opts, 'left') === null
|
||||
&& $holderName !== '' && $holderUrl !== '') {
|
||||
$text = platform_footer_h($raw);
|
||||
$name = platform_footer_h($holderName);
|
||||
$link = '<a href="' . platform_footer_h($holderUrl) . '">' . $name . '</a>';
|
||||
if (str_contains($text, $name)) {
|
||||
return substr_replace($text, $link, strpos($text, $name), strlen($name));
|
||||
}
|
||||
return $link . ($text !== '' ? ' ' . $text : '');
|
||||
}
|
||||
|
||||
return platform_footer_h($raw);
|
||||
}
|
||||
|
||||
/** True when default left uses holder_name + holder_url (skip i18n text replacement). */
|
||||
function platform_footer_left_has_link(array $cfg, array $opts): bool
|
||||
{
|
||||
if (platform_footer_field($cfg, $opts, 'left') !== null) {
|
||||
return false;
|
||||
}
|
||||
$holderName = trim((string) ($cfg['holder_name'] ?? ''));
|
||||
$holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
|
||||
|
||||
return $holderName !== '' && $holderUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve optional row text (null/empty = omit row). Supports per-render overrides in $opts.
|
||||
*
|
||||
* @param array<string, mixed> $cfg
|
||||
* @param array<string, mixed> $opts
|
||||
*/
|
||||
function platform_footer_field(array $cfg, array $opts, string $key): ?string
|
||||
{
|
||||
if (array_key_exists($key, $opts)) {
|
||||
$v = $opts[$key];
|
||||
if ($v === null || $v === false) {
|
||||
return null;
|
||||
}
|
||||
$t = trim((string) $v);
|
||||
|
||||
return $t === '' ? null : $t;
|
||||
}
|
||||
if (!array_key_exists($key, $cfg)) {
|
||||
return null;
|
||||
}
|
||||
$v = $cfg[$key];
|
||||
if ($v === null || $v === false) {
|
||||
return null;
|
||||
}
|
||||
$t = trim((string) $v);
|
||||
|
||||
return $t === '' ? null : $t;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $cfg
|
||||
* @param array<string, mixed> $opts
|
||||
*/
|
||||
function platform_footer_resolve_left(array $cfg, array $opts): ?string
|
||||
{
|
||||
$custom = platform_footer_field($cfg, $opts, 'left');
|
||||
if ($custom !== null) {
|
||||
return $custom;
|
||||
}
|
||||
$built = platform_footer_default_left($cfg);
|
||||
|
||||
return $built === '' ? null : $built;
|
||||
}
|
||||
|
||||
/** @deprecated Use platform_footer_default_left(); kept for scripts/tests. */
|
||||
function platform_footer_plain_text(array $cfg, int $year): string
|
||||
{
|
||||
$cfg = array_merge($cfg, ['year' => $year]);
|
||||
|
||||
return platform_footer_default_left($cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{use_i18n?: bool, extra_class?: string, top?: ?string, left?: ?string, right?: ?string, bottom?: ?string} $opts
|
||||
*/
|
||||
function platform_render_footer(array $opts = []): void
|
||||
{
|
||||
$cfg = platform_footer_config();
|
||||
$top = platform_footer_field($cfg, $opts, 'top');
|
||||
$left = platform_footer_resolve_left($cfg, $opts);
|
||||
$right = platform_footer_field($cfg, $opts, 'right');
|
||||
$bottom = platform_footer_field($cfg, $opts, 'bottom');
|
||||
|
||||
$baseClass = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
|
||||
$classes = trim($baseClass . ' platform-footer');
|
||||
if ($top === null && $bottom === null && $right === null) {
|
||||
$classes .= ' platform-footer--single-row';
|
||||
}
|
||||
|
||||
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
|
||||
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
|
||||
$leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null;
|
||||
$leftHasLink = platform_footer_left_has_link($cfg, $opts);
|
||||
$yearEnd = platform_footer_year_end($cfg);
|
||||
$leftHtml = platform_footer_render_left_html($cfg, $opts);
|
||||
|
||||
echo '<footer class="' . platform_footer_h($classes) . '">';
|
||||
|
||||
if ($top !== null) {
|
||||
echo '<div class="platform-footer__top">' . platform_footer_h($top) . '</div>';
|
||||
}
|
||||
|
||||
if ($left !== null || $right !== null) {
|
||||
echo '<div class="platform-footer__main">';
|
||||
if ($left !== null) {
|
||||
echo '<div class="platform-footer__left"';
|
||||
if ($useI18n && $i18nKey !== '' && $leftUsesDefault && !$leftHasLink) {
|
||||
echo ' data-i18n="' . platform_footer_h($i18nKey) . '"'
|
||||
. ' data-year="' . platform_footer_h(platform_footer_year_label($cfg)) . '"'
|
||||
. ' data-year-end="' . $yearEnd . '"';
|
||||
}
|
||||
echo '>' . ($leftHtml ?? platform_footer_h($left)) . '</div>';
|
||||
}
|
||||
if ($right !== null) {
|
||||
echo '<div class="platform-footer__right">' . platform_footer_h($right) . '</div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
if ($bottom !== null) {
|
||||
echo '<div class="platform-footer__bottom">' . platform_footer_h($bottom) . '</div>';
|
||||
}
|
||||
|
||||
echo '</footer>';
|
||||
}
|
||||
16
platform/scripts/render-footer-html.php
Normal file
16
platform/scripts/render-footer-html.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Emit a static footer.html from footer.config.php (for docs or static-only hosts).
|
||||
*
|
||||
* php examples/platform/scripts/render-footer-html.php > /path/to/footer.html
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/footer.php';
|
||||
|
||||
ob_start();
|
||||
platform_render_footer(['use_i18n' => false]);
|
||||
$footer = ob_get_clean();
|
||||
|
||||
echo $footer . "\n";
|
||||
22
platform/shared_session.php
Normal file
22
platform/shared_session.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Shared session for all Android Cast web consoles (crashes, build, …).
|
||||
* Same cookie path + session name => one login across sub-apps.
|
||||
*/
|
||||
function platform_start_session(string $sessionName = 'ac_crash_sess', string $cookiePath = '/app/androidcast_project'): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
session_name($sessionName);
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => $cookiePath,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
669
src/Database.php
Normal file
669
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;
|
||||
}
|
||||
}
|
||||
401
src/Rbac.php
Normal file
401
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);
|
||||
}
|
||||
}
|
||||
618
src/bootstrap.php
Normal file
618
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