Resources
Documentation, deployment guides, and integration patterns for your environment.
-
Crashes console Builder Git browser
+
Issues console Builder Git browser
diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php
index 1d55b10..a994f44 100644
--- a/examples/crash_reporter/backend/config/config.example.php
+++ b/examples/crash_reporter/backend/config/config.example.php
@@ -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)
diff --git a/examples/crash_reporter/backend/config/config.php b/examples/crash_reporter/backend/config/config.php
index fc1cbfe..3180c35 100644
--- a/examples/crash_reporter/backend/config/config.php
+++ b/examples/crash_reporter/backend/config/config.php
@@ -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',
diff --git a/examples/crash_reporter/backend/nginx.apps-port80.fragment b/examples/crash_reporter/backend/nginx.apps-port80.fragment
index aa7f7ee..7945465 100644
--- a/examples/crash_reporter/backend/nginx.apps-port80.fragment
+++ b/examples/crash_reporter/backend/nginx.apps-port80.fragment
@@ -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;
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index 40e4839..8406f61 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -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;
+}
diff --git a/examples/crash_reporter/backend/public/assets/js/two_factor.js b/examples/crash_reporter/backend/public/assets/js/two_factor.js
new file mode 100644
index 0000000..3e5ec56
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/js/two_factor.js
@@ -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();
+ }
+})();
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index 26b02c9..c7f6ef4 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -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;
}
diff --git a/examples/crash_reporter/backend/scripts/apply-be-port80-nginx.sh b/examples/crash_reporter/backend/scripts/apply-be-port80-nginx.sh
index 35a42c6..7154446 100755
--- a/examples/crash_reporter/backend/scripts/apply-be-port80-nginx.sh
+++ b/examples/crash_reporter/backend/scripts/apply-be-port80-nginx.sh
@@ -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
diff --git a/examples/crash_reporter/backend/src/Auth.php b/examples/crash_reporter/backend/src/Auth.php
index 9de0440..9981227 100644
--- a/examples/crash_reporter/backend/src/Auth.php
+++ b/examples/crash_reporter/backend/src/Auth.php
@@ -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;
+ }
}
diff --git a/examples/crash_reporter/backend/src/AuthTwoFactorPage.php b/examples/crash_reporter/backend/src/AuthTwoFactorPage.php
new file mode 100644
index 0000000..31ecbd6
--- /dev/null
+++ b/examples/crash_reporter/backend/src/AuthTwoFactorPage.php
@@ -0,0 +1,35 @@
+ $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'] ?? ''),
+ ];
+ }
+}
diff --git a/examples/crash_reporter/backend/src/GraphRepository.php b/examples/crash_reporter/backend/src/GraphRepository.php
index 19ade6a..8d01253 100644
--- a/examples/crash_reporter/backend/src/GraphRepository.php
+++ b/examples/crash_reporter/backend/src/GraphRepository.php
@@ -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/',
];
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index f1987dc..9d1f07a 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -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';
}
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index 3f1d51b..e91d811 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -220,7 +220,7 @@