1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:18:09 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-20 21:26:51 +02:00
parent 462962b8dc
commit 26642f46c6
22 changed files with 284 additions and 77 deletions

View File

@@ -11,9 +11,12 @@
* Digest: SHA256 fcd4a89ef4201736923d0e7e1ad88868bba7e39eadb9fcf0ffb9d1ba7efecd12
*/
return [
'app_name' => 'Android Cast Issues',
// Production: '/app/androidcast_project/crashes' — Local php -S: use '' (empty string)
'base_path' => '/app/androidcast_project/crashes',
'app_name' => 'Android Cast',
// Console (issues/tickets/…) — production path
'project_base_path' => '/app/androidcast_project',
'base_path' => '/app/androidcast_project/issues',
// Legacy alias kept for redirects/nginx; do not use in new links
'legacy_base_path' => '/app/androidcast_project/crashes',
'db' => [
// sqlite = default file DB; mysql = MariaDB/MySQL (recommended on BE for concurrency)
'driver' => 'sqlite', // sqlite | mysql | mariadb (alias)

View File

@@ -11,8 +11,9 @@
* Digest: SHA256 8b7149a4cd8a0f7d13ee8a22535c3697e919e35b9fe5bb8a80764769cc6c6853
*/
return [
'app_name' => 'Android Cast Crashes',
'base_path' => '/app/androidcast_project/crashes',
'app_name' => 'Android Cast',
'project_base_path' => '/app/androidcast_project',
'base_path' => '/app/androidcast_project/issues',
'db' => [
'driver' => 'mysql', // sqlite | mysql
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',

View File

@@ -33,40 +33,56 @@ server {
try_files $uri $uri/ =404;
}
location = /app/androidcast_project/issues {
return 301 /app/androidcast_project/issues/;
}
location = /app/androidcast_project/crashes {
return 301 /app/androidcast_project/crashes/;
return 301 /app/androidcast_project/issues/;
}
location ~ ^/app/androidcast_project/crashes(/.*)?$ {
return 301 /app/androidcast_project/issues$1;
}
location ~ ^/app/androidcast_project/(login|logout|register|two-factor|verify-email)(/|$) {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/index.php;
fastcgi_param REQUEST_URI $request_uri;
}
location ^~ /app/androidcast_project/issues/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}
location = /app/androidcast_project/issues/api/upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/api/upload.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ^~ /app/androidcast_project/issues/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location = /app/androidcast_project/graphs {
return 301 /app/androidcast_project/graphs/;
}
location ^~ /app/androidcast_project/crashes/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}
location ^~ /app/androidcast_project/graphs/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}
location = /app/androidcast_project/crashes/api/upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ^~ /app/androidcast_project/crashes/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location = /app/androidcast_project/graphs/api/graphs.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;

View File

@@ -2317,6 +2317,14 @@ button.report-tag--filter:hover {
}
/* Nav icons: live + education */
.nav-icon--live {
width: 18px;
height: 16px;
}
.nav-icon--education {
width: 18px;
height: 16px;
}
.nav-icon--live::before {
content: '';
position: absolute;
@@ -2361,3 +2369,17 @@ button.report-tag--filter:hover {
background: rgba(52, 211, 153, 0.15);
color: #6ee7b7;
}
.twofa-qr-wrap {
margin: 12px 0 16px;
text-align: center;
}
.twofa-qr {
border-radius: 8px;
background: #fff;
padding: 6px;
}
.twofa-qr-wrap figcaption {
margin-top: 8px;
font-size: 0.88rem;
}

View File

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

View File

@@ -15,10 +15,7 @@ require_once __DIR__ . '/../src/bootstrap.php';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$base = Auth::basePath();
if ($base !== '' && str_starts_with($uri, $base)) {
$uri = substr($uri, strlen($base)) ?: '/';
}
$route = rtrim($uri, '/') ?: '/';
$route = resolve_console_route($uri);
if ($route === '/api/upload.php' || str_ends_with($route, '/api/upload.php')) {
require __DIR__ . '/api/upload.php';
@@ -152,7 +149,7 @@ if ($route === '/api/auth_register.php' || str_ends_with($route, '/api/auth_regi
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');
header('Location: ' . Auth::authUrl('/login'));
exit;
}
@@ -165,7 +162,7 @@ if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
if ($result === 'pending_2fa') {
header('Location: ' . $base . '/two-factor');
header('Location: ' . Auth::authUrl('/two-factor'));
exit;
}
$loginError = 'Invalid credentials';
@@ -183,7 +180,7 @@ if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($route === '/login') {
if (Auth::pending2faUserId() > 0) {
header('Location: ' . $base . '/two-factor');
header('Location: ' . Auth::authUrl('/two-factor'));
exit;
}
require __DIR__ . '/../views/login.php';
@@ -235,7 +232,7 @@ if ($route === '/verify-email') {
if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (Auth::pending2faUserId() <= 0) {
header('Location: ' . $base . '/login');
header('Location: ' . Auth::authUrl('/login'));
exit;
}
$code = trim($_POST['code'] ?? '');
@@ -250,9 +247,10 @@ if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($route === '/two-factor') {
if (Auth::pending2faUserId() <= 0) {
header('Location: ' . $base . '/login');
header('Location: ' . Auth::authUrl('/login'));
exit;
}
$twofaQr = AuthTwoFactorPage::qrPayload();
require __DIR__ . '/../views/two_factor_challenge.php';
exit;
}

View File

@@ -15,7 +15,7 @@ from pathlib import Path
frag, apps_path = map(Path, sys.argv[1:3])
text = apps_path.read_text()
start = text.index("server {\n\tlisten 80;")
end = text.index("\n}\n\n\nserver {\n\tlisten 443")
end = text.index("\nserver {\n\tlisten 443")
apps_path.write_text(text[:start] + frag.read_text().strip() + text[end:])
print(f"Updated listen 80 in {apps_path}")
PY

View File

@@ -36,7 +36,7 @@ final class Auth {
public static function check(): void {
if (!self::user()) {
header('Location: ' . self::basePath() . '/login');
header('Location: ' . self::authUrl('/login'));
exit;
}
}
@@ -141,4 +141,27 @@ final class Auth {
$bp = cfg('base_path', '');
return rtrim($bp, '/');
}
/** Shared project prefix, e.g. /app/androidcast_project */
public static function projectBasePath(): string {
$p = trim((string) cfg('project_base_path', ''));
if ($p !== '') {
return rtrim($p, '/');
}
$bp = self::basePath();
if (preg_match('#^(.+)/(?:crashes|issues|graphs|build)$#', $bp, $m)) {
return $m[1];
}
return '/app/androidcast_project';
}
/** Login/logout/register/2FA live at project root, not under /issues/. */
public static function authUrl(string $path = ''): string {
$root = self::projectBasePath();
$path = '/' . ltrim($path, '/');
if ($path === '/' || $path === '') {
return $root;
}
return $root . $path;
}
}

View File

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

View File

@@ -334,8 +334,8 @@ final class GraphRepository {
return [
'hub' => $prefix . '/',
'graphs' => $prefix . '/graphs/',
'crashes' => $prefix . '/crashes/?view=reports',
'tickets' => $prefix . '/crashes/?view=tickets',
'issues' => $prefix . '/issues/?view=reports',
'tickets' => $prefix . '/issues/?view=tickets',
'build' => $prefix . '/build/',
'git' => $prefix . '/git/',
];

View File

@@ -72,6 +72,7 @@ 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) {
@@ -108,6 +109,23 @@ function json_out(array $data, int $code = 200): void {
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'] ?? '');
@@ -117,6 +135,9 @@ function console_base_path(?string $view = null): string {
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';
}

View File

@@ -220,7 +220,7 @@
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.security">Security</span>
</a>
<a href="<?= h(Auth::basePath()) ?>/logout"
<a href="<?= h(Auth::authUrl('/logout')) ?>"
class="nav-link nav-link--logout"
data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
aria-label="Logout"

View File

@@ -22,7 +22,7 @@ $bp = Auth::basePath();
<body class="live-page" data-base-path="<?= h($bp) ?>" data-view="live_education">
<main id="live-education-app" class="live-shell">
<header class="live-header">
<a href="<?= h($bp) ?>/" class="live-brand"><?= h(cfg('app_name')) ?></a>
<a href="<?= h(Auth::projectBasePath()) ?>/" class="live-brand">← Back to the console</a>
<span class="tag-pill">Education demo</span>
</header>
<section class="card card--lift live-card">

View File

@@ -29,7 +29,7 @@ $role = trim((string) ($_GET['role'] ?? ''));
data-direct="<?= $direct ? '1' : '0' ?>"
data-role="<?= h($role) ?>">
<header class="live-header">
<a href="<?= h($bp) ?>/" class="live-brand"><?= h(cfg('app_name')) ?></a>
<a href="<?= h(Auth::projectBasePath()) ?>/" class="live-brand">← Back to the console</a>
<span class="tag-pill tag-pill--ok">Live viewer</span>
</header>
<section class="card card--lift live-card">

View File

@@ -11,6 +11,7 @@
* Digest: SHA256 9bef983f4d34cb790922cda355ccf5d14b565b29273d2ebfe48b3173cd2e8f9f
*/
$bp = Auth::basePath();
$auth = Auth::authUrl();
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
@@ -40,7 +41,7 @@ $bp = Auth::basePath();
</select>
</label>
</div>
<form class="login-card" method="post" action="<?= h($bp) ?>/login">
<form class="login-card" method="post" action="<?= h($auth) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1>
<p class="muted" data-i18n="login.sign_in_hint">Sign in to view anonymous issue reports</p>
<?php if (!empty($loginError)): ?>
@@ -50,7 +51,7 @@ $bp = Auth::basePath();
<label><span data-i18n="login.password">Password</span><input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" data-i18n="login.submit">Sign in</button>
<p class="hint" data-i18n="login.hint_default">Default: admin / admin</p>
<p class="hint"><a href="<?= h($bp) ?>/register" data-i18n="login.register">Register</a></p>
<p class="hint"><a href="<?= h($auth) ?>/register" data-i18n="login.register">Register</a></p>
</form>
<?php platform_render_footer(); ?>
</body>

View File

@@ -1,5 +1,6 @@
<?php
$bp = Auth::basePath();
$auth = Auth::authUrl();
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
@@ -29,7 +30,7 @@ $bp = Auth::basePath();
</select>
</label>
</div>
<form class="login-card" method="post" action="<?= h($bp) ?>/register">
<form class="login-card" method="post" action="<?= h($auth) ?>/register">
<h1 data-i18n="register.title">Create account</h1>
<p class="muted" data-i18n="register.hint">We will email a verification link before you can sign in.</p>
<?php if (!empty($registerError)): ?>
@@ -45,7 +46,7 @@ $bp = Auth::basePath();
<label><span data-i18n="register.password">Password</span>
<input name="password" type="password" autocomplete="new-password" minlength="10" required></label>
<button type="submit" data-i18n="register.submit">Register</button>
<p class="hint"><a href="<?= h($bp) ?>/login" data-i18n="register.back_login">Back to sign in</a></p>
<p class="hint"><a href="<?= h($auth) ?>/login" data-i18n="register.back_login">Back to sign in</a></p>
</form>
<?php platform_render_footer(); ?>
</body>

View File

@@ -1,5 +1,7 @@
<?php
$bp = Auth::basePath();
$auth = Auth::authUrl();
$twofaQr = AuthTwoFactorPage::qrPayload();
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
@@ -17,6 +19,7 @@ $bp = Auth::basePath();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h($bp) ?>/assets/js/two_factor.js" defer></script>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
@@ -28,16 +31,22 @@ $bp = Auth::basePath();
</select>
</label>
</div>
<form class="login-card" method="post" action="<?= h($bp) ?>/two-factor">
<form class="login-card" method="post" action="<?= h($auth) ?>/two-factor" id="twofa-form">
<h1 data-i18n="twofa.title">Two-factor authentication</h1>
<p class="muted" data-i18n="twofa.hint">Enter the 6-digit code from your authenticator app.</p>
<?php if (!empty($twofaError)): ?>
<div class="alert" data-i18n="twofa.error"><?= h($twofaError) ?></div>
<?php endif; ?>
<?php if (is_array($twofaQr) && !empty($twofaQr['qr_url'])): ?>
<figure class="twofa-qr-wrap">
<img src="<?= h((string) $twofaQr['qr_url']) ?>" width="160" height="160" alt="QR code for mobile sign-in link" class="twofa-qr">
<figcaption class="muted">Scan to open the hub on your phone<?php if (!empty($twofaQr['short_url'])): ?> · <a href="<?= h((string) $twofaQr['short_url']) ?>" rel="noopener"><?= h((string) $twofaQr['short_url']) ?></a><?php endif; ?></figcaption>
</figure>
<?php endif; ?>
<label><span data-i18n="twofa.code">Authentication code</span>
<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label>
<input id="twofa-code-input" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label>
<button type="submit" data-i18n="twofa.submit">Continue</button>
<p class="hint"><a href="<?= h($bp) ?>/login" data-i18n="twofa.cancel">Cancel</a></p>
<p class="hint"><a href="<?= h($auth) ?>/login" data-i18n="twofa.cancel">Cancel</a></p>
</form>
<?php platform_render_footer(); ?>
</body>

View File

@@ -1,5 +1,6 @@
<?php
$bp = Auth::basePath();
$auth = Auth::authUrl();
?>
<!DOCTYPE html>
<html lang="en">
@@ -21,10 +22,10 @@ $bp = Auth::basePath();
<h1 data-i18n="verify.title">Email verification</h1>
<?php if (!empty($verifyOk)): ?>
<p class="alert alert--ok" data-i18n="verify.ok">Your email is verified. You can sign in and enroll two-factor authentication.</p>
<p><a class="btn" href="<?= h($bp) ?>/login" data-i18n="verify.sign_in">Sign in</a></p>
<p><a class="btn" href="<?= h($auth) ?>/login" data-i18n="verify.sign_in">Sign in</a></p>
<?php else: ?>
<p class="alert" data-i18n="verify.fail"><?= h($verifyError ?? 'Invalid or expired link.') ?></p>
<p class="hint"><a href="<?= h($bp) ?>/register" data-i18n="verify.register_again">Register again</a></p>
<p class="hint"><a href="<?= h($auth) ?>/register" data-i18n="verify.register_again">Register again</a></p>
<?php endif; ?>
</div>
<?php platform_render_footer(); ?>