1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 05:00:35 +03:00

feat(deploy): lab-seeds sync, msmtp nginx-readable secret, hub downloads

Signed-off-by: Anton Afanasyeu <a.afanasieff@gmail.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 13:53:38 +02:00
parent 624c9d3d7e
commit 465efa8d98
16 changed files with 691 additions and 241 deletions

View File

@@ -19,6 +19,19 @@ if ($action === 'verify') {
json_out($result, $result['ok'] ? 200 : 400); json_out($result, $result['ok'] ? 200 : 400);
} }
if ($action === 'resend') {
$result = AuthRegistration::resendVerification((string) ($data['email'] ?? ''));
json_out($result, $result['ok'] ? 200 : 400);
}
if ($action === 'pending') {
$pending = AuthRegistration::pendingVerification((string) ($data['email'] ?? ''));
if ($pending === null) {
json_out(['ok' => false, 'error' => 'not_pending'], 404);
}
json_out($pending, ($pending['ok'] ?? false) ? 200 : 410);
}
$email = (string) ($data['email'] ?? ''); $email = (string) ($data['email'] ?? '');
$password = (string) ($data['password'] ?? ''); $password = (string) ($data['password'] ?? '');
$username = (string) ($data['username'] ?? ''); $username = (string) ($data['username'] ?? '');

View File

@@ -41,7 +41,7 @@ function hub_docs_section_html(string $section): string
{ {
switch ($section) { switch ($section) {
case 'intro-authors': case 'intro-authors':
return '<p>Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.</p>'; return '<p>Project owner: Anton Afanasyeu. Implementation assistant: Cursor coding agent.</p>';
case 'intro-revision': case 'intro-revision':
return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>'; return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>';
case 'deploy-prep': case 'deploy-prep':

View File

@@ -375,6 +375,37 @@ a:hover { text-decoration: underline; }
left: 10px; left: 10px;
height: 12px; height: 12px;
} }
.nav-icon--monitor {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 50%;
}
.nav-icon--monitor::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 5px;
margin-left: -1px;
margin-top: -5px;
background: currentColor;
transform-origin: bottom center;
transform: rotate(-45deg);
border-radius: 1px;
}
.nav-icon--monitor::after {
content: "";
position: absolute;
left: 50%;
bottom: 2px;
width: 3px;
height: 3px;
margin-left: -1.5px;
background: currentColor;
border-radius: 50%;
}
.ticket-col-issue { min-width: 200px; max-width: 420px; } .ticket-col-issue { min-width: 200px; max-width: 420px; }
.ticket-issue-title { font-weight: 600; } .ticket-issue-title { font-weight: 600; }
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; } .ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
@@ -1561,20 +1592,62 @@ button.report-tag--filter:hover {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
} }
.tag-field input[type="text"] { .tag-field input[type="text"],
padding: 8px 10px; .tag-field input[type="url"],
border-radius: 6px; .tag-field input[type="email"],
.tag-field input[type="number"],
.tag-field input[type="password"],
.tag-field input[type="search"],
.tag-field select,
.tag-field textarea {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-size: 14px;
min-width: 10rem; min-width: 10rem;
} }
.tag-field input:focus,
.tag-field select:focus,
.tag-field textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.tag-field select {
cursor: pointer;
appearance: auto;
}
.tag-editor-actions { .tag-editor-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.data-table select,
#rbac-users-table select {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 14px;
min-width: 8rem;
cursor: pointer;
}
.data-table select:focus,
#rbac-users-table select:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.register-countdown-wrap {
margin: 1rem 0;
font-size: 14px;
}
.register-countdown-wrap strong {
font-variant-numeric: tabular-nums;
margin-left: 0.35rem;
}
.tag-editor-status--err { color: var(--danger); } .tag-editor-status--err { color: var(--danger); }
.tag-preset-bar { .tag-preset-bar {
display: flex; display: flex;

View File

@@ -16,6 +16,8 @@
"nav.access_desc": "Roles, privilege sets, and operators.", "nav.access_desc": "Roles, privilege sets, and operators.",
"nav.builder": "Builder", "nav.builder": "Builder",
"nav.builder_desc": "Docker CI for APK baking and OTA artifacts.", "nav.builder_desc": "Docker CI for APK baking and OTA artifacts.",
"nav.monitor": "Monitoring",
"nav.monitor_desc": "Grafana dashboards — cluster health, alerts, exporters.",
"theme.label": "Theme", "theme.label": "Theme",
"theme.dark": "Dark", "theme.dark": "Dark",
"theme.light": "Light", "theme.light": "Light",
@@ -135,8 +137,7 @@
"login.username": "Username", "login.username": "Username",
"login.password": "Password", "login.password": "Password",
"login.submit": "Sign in", "login.submit": "Sign in",
"login.hint_default": "Default: admin / admin", "login.register": "Create account",
"login.register": "Register",
"login.register_soon": "(coming soon)", "login.register_soon": "(coming soon)",
"login.error": "Invalid credentials", "login.error": "Invalid credentials",
"register.title": "Create account", "register.title": "Create account",
@@ -146,6 +147,10 @@
"register.password": "Password", "register.password": "Password",
"register.submit": "Register", "register.submit": "Register",
"register.back_login": "Back to sign in", "register.back_login": "Back to sign in",
"register.pending_title": "Check your email",
"register.pending_hint": "We sent a verification link. Confirm your email before signing in.",
"register.countdown_label": "Link expires in",
"register.resend": "Resend verification email",
"verify.title": "Email verification", "verify.title": "Email verification",
"verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.", "verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.",
"verify.fail": "Invalid or expired link.", "verify.fail": "Invalid or expired link.",
@@ -153,6 +158,11 @@
"verify.register_again": "Register again", "verify.register_again": "Register again",
"twofa.title": "Two-factor authentication", "twofa.title": "Two-factor authentication",
"twofa.hint": "Enter the 6-digit code from your authenticator app.", "twofa.hint": "Enter the 6-digit code from your authenticator app.",
"twofa.hint_scan": "Scan the QR code with your phone camera (not Google Authenticator) to approve this sign-in, or enter the 6-digit code from your authenticator app below.",
"twofa.qr_caption": "Phone camera → tap link → Approve",
"twofa.waiting_approval": "Waiting for mobile approval…",
"twofa.approved": "Approved — signing you in…",
"twofa.qr_expired": "QR code expired. Reload to get a new one.",
"twofa.code": "Authentication code", "twofa.code": "Authentication code",
"twofa.submit": "Continue", "twofa.submit": "Continue",
"twofa.cancel": "Cancel", "twofa.cancel": "Cancel",
@@ -167,7 +177,7 @@
"security.remove_totp": "Remove authenticator", "security.remove_totp": "Remove authenticator",
"security.back_console": "Back to console", "security.back_console": "Back to console",
"nav.security": "Security", "nav.security": "Security",
"footer.copyright": "© Anton Afanaasyeu, {year}", "footer.copyright": "{holder}, {year}",
"tickets.title": "Tickets", "tickets.title": "Tickets",
"issues.new": "New issue", "issues.new": "New issue",
"tickets.new": "New ticket", "tickets.new": "New ticket",

View File

@@ -26,6 +26,8 @@
"nav.access_desc": "Роли, наборы привилегий и операторы.", "nav.access_desc": "Роли, наборы привилегий и операторы.",
"nav.builder": "Сборщик", "nav.builder": "Сборщик",
"nav.builder_desc": "Docker CI для сборки APK и OTA.", "nav.builder_desc": "Docker CI для сборки APK и OTA.",
"nav.monitor": "Мониторинг",
"nav.monitor_desc": "Дашборды Grafana — состояние кластера, алерты, экспортёры.",
"theme.label": "Тема", "theme.label": "Тема",
"theme.dark": "Тёмная", "theme.dark": "Тёмная",
"theme.light": "Светлая", "theme.light": "Светлая",
@@ -144,8 +146,7 @@
"login.username": "Имя пользователя", "login.username": "Имя пользователя",
"login.password": "Пароль", "login.password": "Пароль",
"login.submit": "Войти", "login.submit": "Войти",
"login.hint_default": "По умолчанию: admin / admin", "login.register": "Создать аккаунт",
"login.register": "Регистрация",
"login.register_soon": "(скоро)", "login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные", "login.error": "Неверные учётные данные",
"register.title": "Создать аккаунт", "register.title": "Создать аккаунт",
@@ -155,6 +156,10 @@
"register.password": "Пароль", "register.password": "Пароль",
"register.submit": "Зарегистрироваться", "register.submit": "Зарегистрироваться",
"register.back_login": "Назад ко входу", "register.back_login": "Назад ко входу",
"register.pending_title": "Проверьте почту",
"register.pending_hint": "Мы отправили ссылку для подтверждения. Подтвердите email перед входом.",
"register.countdown_label": "Ссылка действует",
"register.resend": "Отправить письмо повторно",
"verify.title": "Подтверждение email", "verify.title": "Подтверждение email",
"verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.", "verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.",
"verify.fail": "Недействительная или просроченная ссылка.", "verify.fail": "Недействительная или просроченная ссылка.",
@@ -162,6 +167,11 @@
"verify.register_again": "Зарегистрироваться снова", "verify.register_again": "Зарегистрироваться снова",
"twofa.title": "Двухфакторная аутентификация", "twofa.title": "Двухфакторная аутентификация",
"twofa.hint": "Введите 6-значный код из приложения-аутентификатора.", "twofa.hint": "Введите 6-значный код из приложения-аутентификатора.",
"twofa.hint_scan": "Отсканируйте QR камерой телефона (не Google Authenticator), чтобы подтвердить вход, или введите 6-значный код из приложения-аутентификатора ниже.",
"twofa.qr_caption": "Камера телефона → ссылка → Подтвердить",
"twofa.waiting_approval": "Ожидание подтверждения с телефона…",
"twofa.approved": "Подтверждено — выполняется вход…",
"twofa.qr_expired": "QR-код истёк. Обновите страницу.",
"twofa.code": "Код аутентификации", "twofa.code": "Код аутентификации",
"twofa.submit": "Продолжить", "twofa.submit": "Продолжить",
"twofa.cancel": "Отмена", "twofa.cancel": "Отмена",
@@ -176,7 +186,7 @@
"security.remove_totp": "Отключить аутентификатор", "security.remove_totp": "Отключить аутентификатор",
"security.back_console": "Назад в консоль", "security.back_console": "Назад в консоль",
"nav.security": "Безопасность", "nav.security": "Безопасность",
"footer.copyright": "© Anton Afanaasyeu, {year}", "footer.copyright": "{holder}, {year}",
"nav.tickets": "Тикеты", "nav.tickets": "Тикеты",
"ticket.lifecycle": "Жизненный цикл", "ticket.lifecycle": "Жизненный цикл",
"ticket.assignees": "Исполнители", "ticket.assignees": "Исполнители",

View File

@@ -0,0 +1,131 @@
(function () {
function authBase() {
return document.body.getAttribute('data-auth-base') || '';
}
function apiBase() {
var bp = document.body.getAttribute('data-base-path') || '';
return bp.replace(/\/issues\/?$/, '') + '/api/auth_register.php';
}
function formatCountdown(totalSeconds) {
var s = Math.max(0, totalSeconds);
var m = Math.floor(s / 60);
var r = s % 60;
return m + ':' + String(r).padStart(2, '0');
}
function parseExpiresMs(iso) {
if (!iso) return 0;
var t = Date.parse(iso.replace(' ', 'T') + 'Z');
if (Number.isNaN(t)) {
t = Date.parse(iso);
}
return t;
}
function initCountdown(expiresTs, countdownEl, onExpired) {
var expMs = expiresTs > 0 ? expiresTs * 1000 : 0;
if (!expMs) {
countdownEl.textContent = '—';
return null;
}
function tick() {
var left = Math.ceil((expMs - Date.now()) / 1000);
if (left <= 0) {
countdownEl.textContent = '0:00';
if (onExpired) onExpired();
return;
}
countdownEl.textContent = formatCountdown(left);
}
tick();
return window.setInterval(tick, 1000);
}
async function postJson(body) {
var res = await fetch(apiBase(), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
var data = await res.json().catch(function () { return {}; });
return { ok: res.ok && data.ok !== false, status: res.status, data: data };
}
document.addEventListener('DOMContentLoaded', function () {
var pending = document.getElementById('register-pending');
var form = document.getElementById('register-form');
var countdownEl = document.getElementById('register-countdown');
var resendBtn = document.getElementById('register-resend-btn');
var resendStatus = document.getElementById('register-resend-status');
var emailEl = document.getElementById('register-pending-email');
if (!pending || !countdownEl) return;
var email = document.body.getAttribute('data-register-email') || '';
var expiresTs = parseInt(document.body.getAttribute('data-register-expires-ts') || '0', 10);
var timerId = null;
function showPending(nextEmail, nextExpiresTs) {
if (form) form.hidden = true;
pending.hidden = false;
email = nextEmail || email;
expiresTs = nextExpiresTs || expiresTs;
if (emailEl && email) emailEl.textContent = email;
if (timerId) window.clearInterval(timerId);
timerId = initCountdown(expiresTs, countdownEl, function () {
if (resendStatus) {
resendStatus.textContent = 'Verification link expired — use Resend to get a new one.';
resendStatus.classList.add('error');
}
if (resendBtn) resendBtn.disabled = false;
});
}
if (document.body.getAttribute('data-register-pending') === '1') {
showPending(email, expiresTs);
}
if (resendBtn) {
resendBtn.addEventListener('click', async function () {
if (!email) {
var input = form && form.querySelector('input[name="email"]');
email = input ? input.value.trim() : '';
}
if (!email) {
if (resendStatus) resendStatus.textContent = 'Enter your email on the registration form first.';
return;
}
resendBtn.disabled = true;
if (resendStatus) {
resendStatus.textContent = 'Sending…';
resendStatus.classList.remove('error');
}
var out = await postJson({ action: 'resend', email: email });
if (out.ok && out.data.expires_at) {
var ts = out.data.expires_at ? Math.floor(Date.parse(String(out.data.expires_at).replace(' ', 'T')) / 1000) : 0;
if (!ts && out.data.ttl_seconds) {
ts = Math.floor(Date.now() / 1000) + Number(out.data.ttl_seconds);
}
showPending(email, ts);
if (resendStatus) resendStatus.textContent = 'Verification email sent.';
resendBtn.disabled = false;
return;
}
var err = (out.data && out.data.error) || 'resend_failed';
if (resendStatus) {
resendStatus.textContent = err === 'rate_limited'
? 'Too many attempts — wait a few minutes.'
: err === 'not_pending'
? 'No pending registration for this email.'
: err === 'mail_failed'
? 'Could not send email — contact support.'
: 'Could not resend verification email.';
resendStatus.classList.add('error');
}
resendBtn.disabled = false;
});
}
});
})();

View File

@@ -209,6 +209,8 @@ if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$out = AuthRegistration::register($email, $password, $username); $out = AuthRegistration::register($email, $password, $username);
if ($out['ok']) { if ($out['ok']) {
$registerSuccess = 'Check your email for a verification link.'; $registerSuccess = 'Check your email for a verification link.';
$registerPending = true;
$registerExpiresAt = (string) ($out['expires_at'] ?? '');
require __DIR__ . '/../views/register.php'; require __DIR__ . '/../views/register.php';
exit; exit;
} }
@@ -217,6 +219,7 @@ if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
'weak_password' => 'Password must be at least 10 characters.', 'weak_password' => 'Password must be at least 10 characters.',
'already_registered' => 'An account with this email or username already exists.', 'already_registered' => 'An account with this email or username already exists.',
'rate_limited' => 'Too many attempts — try again later.', 'rate_limited' => 'Too many attempts — try again later.',
'mail_failed' => 'Could not send verification email — try again or contact support.',
default => 'Registration failed.', default => 'Registration failed.',
}; };
require __DIR__ . '/../views/register.php'; require __DIR__ . '/../views/register.php';

View File

@@ -28,7 +28,7 @@ final class AuthMailer {
return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml, $inlineImages); return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml, $inlineImages);
} }
public static function sendVerifyEmail(string $to, string $verifyUrl): bool { public static function sendVerifyEmail(string $to, string $verifyUrl, string $validFor = '24h'): bool {
$subject = 'Verify your Android Cast account'; $subject = 'Verify your Android Cast account';
$shortUrl = $verifyUrl; $shortUrl = $verifyUrl;
$qrImageUrl = null; $qrImageUrl = null;
@@ -49,7 +49,7 @@ final class AuthMailer {
$qrImageUrl = null; $qrImageUrl = null;
} }
} }
$body = "Open this link to verify your email (valid 24h):\n\n" $body = "Open this link to verify your email (valid {$validFor}):\n\n"
. $shortUrl . "\n"; . $shortUrl . "\n";
$bodyHtml = null; $bodyHtml = null;
$inline = []; $inline = [];
@@ -70,7 +70,7 @@ final class AuthMailer {
$cid = 'verify-qr-' . bin2hex(random_bytes(4)) . '@androidcast'; $cid = 'verify-qr-' . bin2hex(random_bytes(4)) . '@androidcast';
$escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$bodyHtml = '<!DOCTYPE html><html><body style="font-family:sans-serif;line-height:1.5">' $bodyHtml = '<!DOCTYPE html><html><body style="font-family:sans-serif;line-height:1.5">'
. '<p>Open this link to verify your email (valid 24h):</p>' . '<p>Open this link to verify your email (valid ' . htmlspecialchars($validFor, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '):</p>'
. '<p><a href="' . $escUrl . '">' . $escUrl . '</a></p>' . '<p><a href="' . $escUrl . '">' . $escUrl . '</a></p>'
. '<p>Or scan this QR code on your phone:</p>' . '<p>Or scan this QR code on your phone:</p>'
. '<p><img src="cid:' . $cid . '" alt="Verify email QR code" width="256" height="256"></p>' . '<p><img src="cid:' . $cid . '" alt="Verify email QR code" width="256" height="256"></p>'

View File

@@ -3,8 +3,6 @@ declare(strict_types=1);
/** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */ /** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */
final class AuthRegistration { final class AuthRegistration {
private const TOKEN_TTL_S = 86400;
private function __construct() { private function __construct() {
} }
@@ -12,7 +10,12 @@ final class AuthRegistration {
AuthEmailSchema::ensure(Database::pdo()); AuthEmailSchema::ensure(Database::pdo());
} }
/** @return array{ok:bool,error?:string} */ public static function verifyEmailTtlSeconds(): int {
$ttl = (int) cfg('auth.verify_email_ttl', 86400);
return max(60, min($ttl, 604800));
}
/** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */
public static function register(string $email, string $password, string $username = ''): array { public static function register(string $email, string $password, string $username = ''): array {
self::ensureSchema(); self::ensureSchema();
if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) { if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) {
@@ -32,9 +35,14 @@ final class AuthRegistration {
$username = strstr($emailNorm, '@', true) ?: $emailNorm; $username = strstr($emailNorm, '@', true) ?: $emailNorm;
} }
$pdo = Database::pdo(); $pdo = Database::pdo();
$st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); self::purgeStalePending($pdo);
$st = $pdo->prepare('SELECT id, status FROM users WHERE email_normalized = ? OR username = ? LIMIT 1');
$st->execute([$emailNorm, $username]); $st->execute([$emailNorm, $username]);
if ($st->fetchColumn()) { $existing = $st->fetch(PDO::FETCH_ASSOC);
if ($existing) {
if (($existing['status'] ?? '') === 'pending') {
return self::resendVerification($emailNorm);
}
AuthAttempts::record('register_fail', $username); AuthAttempts::record('register_fail', $username);
return ['ok' => false, 'error' => 'already_registered']; return ['ok' => false, 'error' => 'already_registered'];
} }
@@ -49,21 +57,117 @@ final class AuthRegistration {
'pending', 'pending',
]); ]);
$userId = (int) $pdo->lastInsertId(); $userId = (int) $pdo->lastInsertId();
$sent = self::issueVerifyToken($pdo, $userId, $emailNorm);
if (!$sent['ok']) {
return $sent;
}
AuthAttempts::record('register_ok', $username);
return [
'ok' => true,
'expires_at' => $sent['expires_at'],
'ttl_seconds' => self::verifyEmailTtlSeconds(),
'email' => $emailNorm,
];
}
/** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */
public static function resendVerification(string $email): array {
self::ensureSchema();
$emailNorm = self::normalizeEmail($email);
if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'error' => 'invalid_email'];
}
if (AuthAttempts::isRateLimited('resend:' . $emailNorm)) {
return ['ok' => false, 'error' => 'rate_limited'];
}
$pdo = Database::pdo();
self::purgeStalePending($pdo);
$st = $pdo->prepare(
"SELECT id FROM users WHERE email_normalized = ? AND status = 'pending' AND email_verified_at IS NULL LIMIT 1"
);
$st->execute([$emailNorm]);
$userId = (int) $st->fetchColumn();
if ($userId <= 0) {
return ['ok' => false, 'error' => 'not_pending'];
}
$pdo->prepare(
"UPDATE auth_tokens SET used_at = ? WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL"
)->execute([date('Y-m-d H:i:s'), $userId]);
$sent = self::issueVerifyToken($pdo, $userId, $emailNorm);
if (!$sent['ok']) {
return $sent;
}
AuthAttempts::record('register_resend', $emailNorm);
return [
'ok' => true,
'expires_at' => $sent['expires_at'],
'ttl_seconds' => self::verifyEmailTtlSeconds(),
'email' => $emailNorm,
];
}
/**
* Pending verification window for countdown UI.
*
* @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string}|null
*/
public static function pendingVerification(string $email): ?array {
self::ensureSchema();
$emailNorm = self::normalizeEmail($email);
if ($emailNorm === '') {
return null;
}
$pdo = Database::pdo();
$st = $pdo->prepare(
"SELECT u.id FROM users u
WHERE u.email_normalized = ? AND u.status = 'pending' AND u.email_verified_at IS NULL
LIMIT 1"
);
$st->execute([$emailNorm]);
$userId = (int) $st->fetchColumn();
if ($userId <= 0) {
return null;
}
$tok = $pdo->prepare(
"SELECT expires_at FROM auth_tokens
WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL
ORDER BY id DESC LIMIT 1"
);
$tok->execute([$userId]);
$expiresAt = (string) ($tok->fetchColumn() ?: '');
if ($expiresAt === '') {
return null;
}
$exp = strtotime($expiresAt);
if ($exp !== false && $exp < time()) {
return ['ok' => false, 'error' => 'expired_token', 'email' => $emailNorm];
}
return [
'ok' => true,
'expires_at' => $expiresAt,
'ttl_seconds' => self::verifyEmailTtlSeconds(),
'email' => $emailNorm,
];
}
/** @return array{ok:bool,error?:string,expires_at?:string} */
private static function issueVerifyToken(\PDO $pdo, int $userId, string $emailNorm): array {
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token); $tokenHash = hash('sha256', $token);
$expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S); $ttl = self::verifyEmailTtlSeconds();
$expires = date('Y-m-d H:i:s', time() + $ttl);
$pdo->prepare( $pdo->prepare(
'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at) 'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?)'
)->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]); )->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]);
$base = rtrim((string) cfg('base_path', ''), '/');
$origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/'); $origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/');
$verifyUrl = $origin . $base . '/verify-email?token=' . urlencode($token); $verifyUrl = $origin . Auth::authUrl('/verify-email') . '?token=' . urlencode($token);
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) { $ttlLabel = self::formatTtlLabel($ttl);
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl, $ttlLabel)) {
error_log('AuthRegistration: verify mail failed for ' . $emailNorm); error_log('AuthRegistration: verify mail failed for ' . $emailNorm);
return ['ok' => false, 'error' => 'mail_failed'];
} }
AuthAttempts::record('register_ok', $username); return ['ok' => true, 'expires_at' => $expires];
return ['ok' => true];
} }
/** @return array{ok:bool,error?:string} */ /** @return array{ok:bool,error?:string} */
@@ -96,7 +200,39 @@ final class AuthRegistration {
return ['ok' => true]; return ['ok' => true];
} }
private static function formatTtlLabel(int $ttlSeconds): string {
if ($ttlSeconds >= 86400 && $ttlSeconds % 86400 === 0) {
$days = (int) ($ttlSeconds / 86400);
return $days === 1 ? '24h' : $days . ' days';
}
if ($ttlSeconds >= 3600 && $ttlSeconds % 3600 === 0) {
$hours = (int) ($ttlSeconds / 3600);
return $hours === 1 ? '1 hour' : $hours . ' hours';
}
$mins = max(1, (int) ceil($ttlSeconds / 60));
return $mins === 1 ? '1 minute' : $mins . ' minutes';
}
private static function normalizeEmail(string $email): string { private static function normalizeEmail(string $email): string {
return strtolower(trim($email)); return strtolower(trim($email));
} }
/** Drop unverified accounts started more than verify TTL ago. */
private static function purgeStalePending(\PDO $pdo): void {
$cutoff = date('Y-m-d H:i:s', time() - self::verifyEmailTtlSeconds());
$st = $pdo->prepare(
"SELECT id FROM users WHERE status = 'pending' AND email_verified_at IS NULL
AND (created_at IS NULL OR created_at < ?)"
);
$st->execute([$cutoff]);
$ids = $st->fetchAll(\PDO::FETCH_COLUMN);
foreach ($ids as $id) {
$uid = (int) $id;
if ($uid <= 0) {
continue;
}
$pdo->prepare('DELETE FROM auth_tokens WHERE user_id = ?')->execute([$uid]);
$pdo->prepare('DELETE FROM users WHERE id = ? AND status = ?')->execute([$uid, 'pending']);
}
}
} }

View File

@@ -11,6 +11,12 @@
* Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a * Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a
*/ */
?> ?>
<?php
$__navProjectBase = Auth::projectBasePath();
$__navIssuesBase = rtrim((string) cfg('links.crashes', $__navProjectBase . '/issues'), '/');
$__navActive = platform_nav_active_from_view($view ?? null);
$__navUser = trim((string) (Auth::user()['username'] ?? ''));
?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="en" data-theme="dark">
<head> <head>
@@ -25,8 +31,6 @@
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l); if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})(); })();
</script> </script>
<link rel="icon" type="image/x-icon" href="<?= h(Auth::basePath()) ?>/assets/favicon.ico">
<link rel="apple-touch-icon" href="<?= h(Auth::basePath()) ?>/assets/apple-touch-icon.ico">
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css"> <link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script> <script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script> <script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
@@ -51,6 +55,19 @@
<?php if (($view ?? '') === 'rbac'): ?> <?php if (($view ?? '') === 'rbac'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script> <script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
<?php endif; ?> <?php endif; ?>
<?php if (($view ?? '') === 'media_pipelines'): ?>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/vendor/nuevo-player/vdjs/v10.0.0/skins/treso/videojs.min.css">
<style>
.media-pipelines-player-wrap { max-width: 960px; margin: 0.5rem 0 1rem; background: var(--surface-2, #111); border-radius: 8px; overflow: hidden; }
.media-pipelines-player-wrap .video-js { width: 100%; }
.tag-field--wide { flex: 2 1 280px; min-width: 200px; }
.btn--sm { font-size: 0.8rem; padding: 0.2rem 0.5rem; margin-right: 0.25rem; }
</style>
<script src="<?= h(Auth::basePath()) ?>/assets/vendor/nuevo-player/js/video.min.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/vendor/nuevo-player/js/videojs-contrib-hls.min.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/vendor/nuevo-player/js/nuevo.min.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/media_pipelines.js" defer></script>
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?> <?php AnalyticsHead::render('crashes'); ?>
</head> </head>
<body data-base-path="<?= h(console_base_path($view ?? null)) ?>" <body data-base-path="<?= h(console_base_path($view ?? null)) ?>"
@@ -62,188 +79,16 @@
<?= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?> <?= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?>
<?= Rbac::can('short_links_operate') ? ' data-can-sl-operate="1"' : '' ?> <?= Rbac::can('short_links_operate') ? ' data-can-sl-operate="1"' : '' ?>
<?= Rbac::isGlobalAdmin() ? ' data-can-sl-admin="1"' : '' ?> <?= Rbac::isGlobalAdmin() ? ' data-can-sl-admin="1"' : '' ?>
<?= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>> <?= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>
<?= (cfg('media_pipelines.enabled', false) && (Rbac::isGlobalAdmin() || Rbac::can('media_pipelines_operate'))) ? ' data-can-mp-operate="1"' : '' ?>>
<header class="top-menu" hidden aria-hidden="true"></header> <header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell<?= ($view ?? '') === 'graphs' ? ' shell--graphs-full' : '' ?>"> <div class="shell<?= ($view ?? '') === 'graphs' ? ' shell--graphs-full' : '' ?>">
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation"> <?php platform_render_nav_shell([
<button type="button" class="nav-handle" id="nav-handle" data-i18n-aria="nav.toggle" data-i18n-title="nav.toggle" aria-label="Toggle navigation" title="Navigation"> 'project_base' => $__navProjectBase,
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span> 'issues_base' => $__navIssuesBase,
</button> 'active' => $__navActive,
<ul class="nav-list"> 'username' => $__navUser,
<li> ]); ?>
<a href="<?= h(Auth::basePath()) ?>/?view=home"
class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"
data-i18n-aria="nav.home" data-i18n-title="nav.home"
aria-label="Home"
title="Home">
<span class="nav-icon nav-icon--home" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.home">Home</span>
<span class="nav-desc" data-i18n="nav.home_desc">Console home and workspace cards.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=reports"
class="nav-link <?= ($view ?? '') === 'reports' || ($view ?? '') === 'report' ? 'active' : '' ?>"
data-i18n-aria="nav.reports" data-i18n-title="nav.reports"
aria-label="Reports"
title="Reports">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.reports">Reports</span>
<span class="nav-desc" data-i18n="nav.reports_desc">Browse and triage issues.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=tickets"
class="nav-link <?= ($view ?? '') === 'tickets' || ($view ?? '') === 'ticket' ? 'active' : '' ?>"
data-i18n-aria="nav.tickets" data-i18n-title="nav.tickets"
aria-label="Tickets"
title="Tickets">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.tickets">Tickets</span>
<span class="nav-desc" data-i18n="nav.tickets_desc">Roadmap tasks, QA items, and tags.</span>
</span>
</a>
</li>
<li>
<a href="/app/androidcast_project/graphs/"
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
aria-label="Analytics"
title="Analytics">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.graphs">Analytics</span>
<span class="nav-desc" data-i18n="nav.graphs_desc">Sessions, issues, and device activity.</span>
</span>
</a>
</li>
<li>
<a href="/app/androidcast_project/monitor/"
class="nav-link"
aria-label="Monitoring"
title="Monitoring">
<span class="nav-icon nav-icon--monitor" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.monitor">Monitoring</span>
<span class="nav-desc" data-i18n="nav.monitor_desc">Grafana dashboards — cluster health, alerts, exporters.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=live_sessions"
class="nav-link <?= ($view ?? '') === 'live_sessions' ? 'active' : '' ?>"
aria-label="Live sessions"
title="Live sessions">
<span class="nav-icon nav-icon--live" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label">Live sessions</span>
<span class="nav-desc">Cast intents, join stats, and session history.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/live/education"
class="nav-link"
aria-label="Education demo"
title="Education demo">
<span class="nav-icon nav-icon--education" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label">Education demo</span>
<span class="nav-desc">Browser screen-share trial (5 min).</span>
</span>
</a>
</li>
<?php if (Rbac::can('remote_access_view')): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
class="nav-link <?= ($view ?? '') === 'remote_access' ? 'active' : '' ?>"
aria-label="Remote access"
title="Remote access">
<span class="nav-icon nav-icon--remote" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.remote">Remote access</span>
<span class="nav-desc" data-i18n="nav.remote_desc">WireGuard sessions and device reachability.</span>
</span>
</a>
</li>
<?php endif; ?>
<?php if (Rbac::can('short_links_view')): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=short_links"
class="nav-link <?= ($view ?? '') === 'short_links' ? 'active' : '' ?>"
aria-label="Short links"
title="Short links">
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="home.card_short_links">Short links</span>
<span class="nav-desc" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</span>
</span>
</a>
</li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=rbac"
class="nav-link <?= ($view ?? '') === 'rbac' ? 'active' : '' ?>"
aria-label="Access control"
title="Access control">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.access">Access</span>
<span class="nav-desc" data-i18n="nav.access_desc">Roles, privilege sets, and operators.</span>
</span>
</a>
</li>
<?php endif; ?>
<li>
<a href="/app/androidcast_project/build/"
class="nav-link"
aria-label="Builder"
title="Builder">
<span class="nav-icon nav-icon--builder" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.builder">Builder</span>
<span class="nav-desc" data-i18n="nav.builder_desc">Docker CI for APK baking and OTA artifacts.</span>
</span>
</a>
</li>
</ul>
<div class="nav-locale">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" id="locale-flag" aria-hidden="true">🇬🇧</span>
<select class="lang-select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
</div>
<div class="nav-user">
<span class="nav-user-name" title="<?= h(Auth::user()['username'] ?? '') ?>">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label"><?= h(Auth::user()['username'] ?? '') ?></span>
</span>
<a href="<?= h(Auth::basePath()) ?>/account-security"
class="nav-link"
data-i18n-aria="nav.security" data-i18n-title="nav.security"
aria-label="Security"
title="Security">
<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::authUrl('/logout')) ?>"
class="nav-link nav-link--logout"
data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
aria-label="Logout"
title="Logout">
<span class="nav-icon nav-icon--logout" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.logout">Logout</span>
</a>
</div>
</nav>
<main class="main-pane"> <main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?> <?php if (($view ?? 'home') === 'home'): ?>
<?php require __DIR__ . '/partials/console_home_landing.php'; ?> <?php require __DIR__ . '/partials/console_home_landing.php'; ?>
@@ -607,6 +452,91 @@
</div> </div>
</section> </section>
</div> </div>
<?php elseif (($view ?? '') === 'media_pipelines'): ?>
<div id="media-pipelines-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Media pipelines <span class="muted" style="font-size:0.65em;font-weight:normal">(experimental)</span></h1>
<div class="toolbar-actions">
<button type="button" class="btn" id="mp-refresh">Refresh</button>
</div>
</div>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="mp-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">
Lab-only restream control plane. Requires <code>streaming_v2</code> on BE, ffmpeg, yt-dlp.
Visible when <code>media_pipelines.enabled</code> and global admin / privilege grant.
</p>
<section class="graphs-scope" id="mp-create-section">
<h2 class="graphs-scope-title">New pipeline</h2>
<form id="mp-create-form" class="tag-editor-add">
<label class="tag-field"><span>Name</span><input type="text" name="name" maxlength="128" placeholder="My restream"></label>
<label class="tag-field tag-field--wide"><span>Source URL</span><input type="url" name="source_url" required placeholder="https://… or /path/file.mkv"></label>
<label class="tag-field"><span>yt-dlp format</span><input type="text" name="source_fmt" placeholder="bv*+ba/b"></label>
<label class="tag-field tag-field--wide"><span>Separate audio URL</span><input type="url" name="audio_source" placeholder="optional"></label>
<label class="tag-field">
<span>Filter</span>
<select name="filter_profile">
<option value="default">Logo overlay</option>
<option value="blurred">Blurred + logo</option>
<option value="nologo">No logo</option>
</select>
</label>
<label class="tag-field">
<span>Preset</span>
<select name="output_preset">
<option value="1080p">1080p</option>
<option value="720p">720p</option>
</select>
</label>
<label class="tag-field">
<span>Mode</span>
<select name="run_mode">
<option value="full">Full (encode + RTMP fanout)</option>
<option value="encode_only">Encode only (record + HLS)</option>
</select>
</label>
<button type="submit" class="btn" id="mp-create-btn">Create pipeline</button>
</form>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Pipelines</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="mp-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Mode</th>
<th>Source</th>
<th>HLS</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="mp-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope" id="mp-player-section" hidden>
<h2 class="graphs-scope-title">Preview</h2>
<div class="media-pipelines-player-wrap">
<video id="mp-player" class="video-js vjs-default-skin vjs-big-play-centered" controls preload="auto" width="960" height="540"></video>
</div>
<p class="muted graphs-footnote" id="mp-player-url"></p>
</section>
<section class="graphs-scope" id="mp-probe-section" hidden>
<h2 class="graphs-scope-title">Source profile (ffprobe)</h2>
<pre id="mp-probe-json" class="graph-detail-body" style="max-height:240px;overflow:auto"></pre>
</section>
<section class="graphs-scope" id="mp-logs-section" hidden>
<h2 class="graphs-scope-title">Encode log (tail)</h2>
<pre id="mp-logs" class="graph-detail-body" style="max-height:200px;overflow:auto;font-size:0.75rem"></pre>
</section>
</div>
<?php elseif (($view ?? '') === 'short_links'): ?> <?php elseif (($view ?? '') === 'short_links'): ?>
<div id="short-links-app" class="reports-app"> <div id="short-links-app" class="reports-app">
<div class="toolbar reports-toolbar"> <div class="toolbar reports-toolbar">

View File

@@ -1,6 +1,9 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
$bp = Auth::basePath(); $bp = Auth::basePath();
$projectBase = Auth::projectBasePath();
$issuesBase = rtrim((string) cfg('links.crashes', $projectBase . '/issues'), '/');
$navUser = trim((string) (Auth::user()['username'] ?? ''));
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="en" data-theme="dark">
@@ -12,21 +15,31 @@ $bp = Auth::basePath();
(function () { (function () {
var t = localStorage.getItem('crash_console_theme'); var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t); if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})(); })();
</script> </script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css"> <link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/nav_shell.js" defer></script>
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script> <script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
<?php AnalyticsHead::render('live_education'); ?> <?php AnalyticsHead::render('live_education'); ?>
<script src="<?= h($bp) ?>/assets/js/live_cast_webrtc.js" defer></script> <script src="<?= h($bp) ?>/assets/js/live_cast_webrtc.js" defer></script>
<script src="<?= h($bp) ?>/assets/js/live_education.js" defer></script> <script src="<?= h($bp) ?>/assets/js/live_education.js" defer></script>
</head> </head>
<body class="live-page" data-base-path="<?= h($bp) ?>" data-view="live_education"> <body class="live-page" data-base-path="<?= h($bp) ?>" data-view="live_education">
<main id="live-education-app" class="live-shell"> <div class="shell">
<?php platform_render_nav_shell([
'project_base' => $projectBase,
'issues_base' => $issuesBase,
'active' => 'education',
'username' => $navUser,
]); ?>
<main class="main-pane" id="live-education-app">
<section class="card card--lift live-card">
<header class="live-header"> <header class="live-header">
<a href="<?= h(Auth::projectBasePath()) ?>/" class="live-brand">← Back to the console</a>
<span class="tag-pill">Education demo</span> <span class="tag-pill">Education demo</span>
</header> </header>
<section class="card card--lift live-card">
<h1>Try screen sharing in your browser</h1> <h1>Try screen sharing in your browser</h1>
<p class="muted">Free demo up to 5 minutes. No install required — great for first-time visitors.</p> <p class="muted">Free demo up to 5 minutes. No install required — great for first-time visitors.</p>
<p id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p> <p id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p>
@@ -44,6 +57,7 @@ $bp = Auth::basePath();
</p> </p>
</section> </section>
</main> </main>
</div>
<?php require __DIR__ . '/partials/cookie_consent.php'; ?> <?php require __DIR__ . '/partials/cookie_consent.php'; ?>
<?php platform_render_footer(); ?> <?php platform_render_footer(); ?>
</body> </body>

View File

@@ -1,6 +1,9 @@
<?php <?php
$bp = Auth::basePath(); $bp = Auth::basePath();
$auth = Auth::authUrl(); $auth = Auth::authUrl();
$registerPending = !empty($registerPending);
$registerExpiresAt = (string) ($registerExpiresAt ?? '');
$registerEmail = (string) ($registerEmail ?? '');
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="en" data-theme="dark">
@@ -16,11 +19,18 @@ $auth = Auth::authUrl();
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l); if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})(); })();
</script> </script>
<link rel="icon" type="image/x-icon" href="<?= h($bp) ?>/assets/favicon.ico">
<link rel="apple-touch-icon" href="<?= h($bp) ?>/assets/apple-touch-icon.ico">
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css"> <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/i18n.js" defer></script>
<script src="<?= h($bp) ?>/assets/js/register.js" defer></script>
<?php AnalyticsHead::render('crashes_register'); ?> <?php AnalyticsHead::render('crashes_register'); ?>
</head> </head>
<body class="login-page" data-base-path="<?= h($bp) ?>"> <body class="login-page" data-base-path="<?= h($bp) ?>"
data-auth-base="<?= h($auth) ?>"
<?= $registerPending ? ' data-register-pending="1"' : '' ?>
<?= $registerExpiresAt !== '' ? ' data-register-expires-ts="' . (int) strtotime($registerExpiresAt) . '"' : '' ?>
<?= $registerEmail !== '' ? ' data-register-email="' . h($registerEmail) . '"' : '' ?>>
<div class="login-locale"> <div class="login-locale">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language"> <label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" aria-hidden="true">🇬🇧</span> <span class="locale-flag" aria-hidden="true">🇬🇧</span>
@@ -30,17 +40,14 @@ $auth = Auth::authUrl();
</select> </select>
</label> </label>
</div> </div>
<form class="login-card" method="post" action="<?= h($auth) ?>/register"> <form class="login-card" id="register-form" method="post" action="<?= h($auth) ?>/register"<?= $registerPending ? ' hidden' : '' ?>>
<h1 data-i18n="register.title">Create account</h1> <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> <p class="muted" data-i18n="register.hint">We will email a verification link before you can sign in.</p>
<?php if (!empty($registerError)): ?> <?php if (!empty($registerError)): ?>
<div class="alert"><?= h($registerError) ?></div> <div class="alert"><?= h($registerError) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($registerSuccess)): ?>
<div class="alert alert--ok"><?= h($registerSuccess) ?></div>
<?php endif; ?>
<label><span data-i18n="register.email">Email</span> <label><span data-i18n="register.email">Email</span>
<input name="email" type="email" autocomplete="email" required value="<?= h($registerEmail ?? '') ?>"></label> <input name="email" type="email" autocomplete="email" required value="<?= h($registerEmail) ?>"></label>
<label><span data-i18n="register.username">Username</span> <label><span data-i18n="register.username">Username</span>
<input name="username" autocomplete="username" value="<?= h($registerUsername ?? '') ?>"></label> <input name="username" autocomplete="username" value="<?= h($registerUsername ?? '') ?>"></label>
<label><span data-i18n="register.password">Password</span> <label><span data-i18n="register.password">Password</span>
@@ -48,6 +55,22 @@ $auth = Auth::authUrl();
<button type="submit" data-i18n="register.submit">Register</button> <button type="submit" data-i18n="register.submit">Register</button>
<p class="hint"><a href="<?= h($auth) ?>/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> </form>
<section class="login-card" id="register-pending"<?= $registerPending ? '' : ' hidden' ?>>
<h1 data-i18n="register.pending_title">Check your email</h1>
<p class="muted" data-i18n="register.pending_hint">We sent a verification link. Confirm your email before signing in.</p>
<?php if (!empty($registerSuccess)): ?>
<div class="alert alert--ok"><?= h($registerSuccess) ?></div>
<?php endif; ?>
<p id="register-pending-email" class="muted"><?= $registerEmail !== '' ? h($registerEmail) : '' ?></p>
<p class="register-countdown-wrap">
<span data-i18n="register.countdown_label">Link expires in</span>
<strong id="register-countdown" aria-live="polite">—</strong>
</p>
<p id="register-resend-status" class="muted" aria-live="polite"></p>
<button type="button" class="btn" id="register-resend-btn" data-i18n="register.resend">Resend verification email</button>
<p class="hint"><a href="<?= h($auth) ?>/login" data-i18n="register.back_login">Back to sign in</a></p>
</section>
<?php platform_render_footer(); ?> <?php platform_render_footer(); ?>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
$ab = assets_base();
$links = cfg('links', []);
$projectBase = Auth::projectBasePath();
$issuesBase = rtrim((string) cfg('links.crashes', $projectBase . '/issues'), '/');
$navActive = 'builder';
$user = Auth::user();
$navUser = is_array($user) ? trim((string) ($user['username'] ?? '')) : '';
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle ?? 'Builder') ?> — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($ab) ?>/assets/css/app.css">
<script src="<?= h($ab) ?>/assets/js/nav_shell.js" defer></script>
<script src="<?= h($ab) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/builder.js" defer></script>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
data-view="<?= h($view ?? 'home') ?>"
<?= (($view ?? '') === 'build' && !empty($build['id'])) ? ' data-build-id="' . (int) $build['id'] . '"' : '' ?>
<?= (($view ?? '') === 'build' && !empty($build['status'])) ? ' data-build-status="' . h((string) $build['status']) . '"' : '' ?>>
<div class="shell">
<?php platform_render_nav_shell([
'project_base' => $projectBase,
'issues_base' => $issuesBase,
'active' => $navActive,
'username' => $navUser,
]); ?>
<main class="main-pane">
<div class="toolbar reports-toolbar" style="margin-bottom:12px">
<div class="toolbar-actions" style="margin-left:auto">
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
</div>
</div>
<?php if (($view ?? '') === 'build' && !empty($build)): ?>
<?php require __DIR__ . '/build_detail.php'; ?>
<?php else: ?>
<?php require __DIR__ . '/home.php'; ?>
<?php endif; ?>
</main>
</div>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -363,13 +363,20 @@ $config['build'] = array_merge([
'ci_version' => '00.01.00.1000', 'ci_version' => '00.01.00.1000',
'repo_root' => '/var/www/ac/workspace', 'repo_root' => '/var/www/ac/workspace',
'artifacts_root' => '/var/www/ac/broadcast/builds', 'artifacts_root' => '/var/www/ac/broadcast/builds',
'ota_mount' => '/var/www/ac/broadcast/ota-artifacts', 'ota_mount' => '/var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts',
'downloads_mount' => '/var/www/localhost/htdocs/apps/app/androidcast_project/downloads',
'ota_base_url' => 'https://apps.f0xx.org', 'ota_base_url' => 'https://apps.f0xx.org',
'runner_script' => '/var/www/ac/workspace/ac-scripts/docker-build-runner.sh', 'runner_script' => '/var/www/ac/workspace/ac-scripts/docker-build-runner.sh',
'isolate_source' => true, 'isolate_source' => true,
'git_clone_depth' => 1, 'git_clone_depth' => 1,
'pipeline_config' => '/var/www/ac/workspace/ac-scripts/build.config.yml', 'pipeline_config' => '/var/www/ac/workspace/ac-scripts/build.config.yml',
'default_channels' => ['stable', 'staging', 'dev', 'nightly'], 'default_channels' => ['stable', 'staging', 'dev', 'nightly'],
'mobile_subdir' => 'ac-mobile-android',
'scripts_dir' => '/var/www/ac/workspace/ac-scripts',
'notify' => [
'admin_email' => 'bestcastr@gmail.com',
'broadcast_config' => '/var/www/ac/workspace/ac-ms-broadcast/config/config.php',
],
], $config['build'] ?? []); ], $config['build'] ?? []);
return $config; return $config;
PHP PHP

View File

@@ -1,6 +1,5 @@
#!/bin/sh #!/bin/sh
# Publish hub download artifacts (Session Studio jar) to BE downloads mount. # Publish hub download artifacts (Session Studio jar + latest OTA .apk) to BE downloads mount.
# APK is served via OTA stable channel (/v0/ota/) — run ci-build-and-publish-ota.sh separately.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=/dev/null # shellcheck source=/dev/null
@@ -8,6 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
load_cluster_env load_cluster_env
DOWNLOADS_ROOT="${APP_ROOT:-/var/www/localhost/htdocs/apps/app/androidcast_project}/downloads" DOWNLOADS_ROOT="${APP_ROOT:-/var/www/localhost/htdocs/apps/app/androidcast_project}/downloads"
OTA_ROOT="${APP_ROOT:-/var/www/localhost/htdocs/apps/app/androidcast_project}/ota-artifacts/v0/ota"
WS="${AC_WORKSPACE_ROOT:-/var/www/ac/workspace}" WS="${AC_WORKSPACE_ROOT:-/var/www/ac/workspace}"
STUDIO_JAR="${WS}/ac-session-studio/build/libs/ac-session-studio.jar" STUDIO_JAR="${WS}/ac-session-studio/build/libs/ac-session-studio.jar"
@@ -18,8 +18,44 @@ if [ -f "$STUDIO_JAR" ]; then
log "installed Session Studio jar → ${DOWNLOADS_ROOT}/ac-session-studio.jar" log "installed Session Studio jar → ${DOWNLOADS_ROOT}/ac-session-studio.jar"
else else
log "WARN: Session Studio jar not built at ${STUDIO_JAR}" log "WARN: Session Studio jar not built at ${STUDIO_JAR}"
log " cd ac-session-studio && ./gradlew shadowJar (or installDist) on builder node"
fi fi
# Prefer staging channel APK; fall back to stable.
apk_src=""
for ch in staging stable; do
channel_json="${OTA_ROOT}/channel/${ch}.json"
[ -f "$channel_json" ] || continue
manifest_url="$(sed -n 's/.*"manifestUrl"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$channel_json" | head -1)"
[ -n "$manifest_url" ] || continue
manifest_path="${OTA_ROOT}/${manifest_url#*\/v0\/ota\/}"
[ -f "$manifest_path" ] || continue
browser_url="$(sed -n 's/.*"browserApkUrl"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest_path" | head -1)"
if [ -n "$browser_url" ]; then
candidate="${OTA_ROOT}/${browser_url#*\/v0\/ota\/}"
if [ -f "$candidate" ]; then
apk_src="$candidate"
break
fi
fi
otapkg_url="$(sed -n 's/.*"apkUrl"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest_path" | head -1)"
if [ -n "$otapkg_url" ]; then
candidate="${OTA_ROOT}/${otapkg_url#*\/v0\/ota\/}"
if [ -f "$candidate" ]; then
apk_src="$candidate"
break
fi
fi
done
if [ -n "$apk_src" ]; then
install -m 644 "$apk_src" "${DOWNLOADS_ROOT}/android_cast-latest.apk"
base="$(basename "$apk_src" .otapkg)"
base="$(basename "$base" .apk)"
install -m 644 "$apk_src" "${DOWNLOADS_ROOT}/${base}.apk"
log "installed APK → ${DOWNLOADS_ROOT}/android_cast-latest.apk (from ${apk_src})"
else
log "WARN: no OTA .apk/.otapkg found under ${OTA_ROOT}/channel/{staging,stable}.json"
fi
chown -R nginx:nginx "$DOWNLOADS_ROOT" 2>/dev/null || true
log "Hub downloads dir: ${DOWNLOADS_ROOT}" log "Hub downloads dir: ${DOWNLOADS_ROOT}"
log "URL: ${PUBLIC_ORIGIN:-https://apps.f0xx.org}/v0/downloads/ac-session-studio.jar"

View File

@@ -19,7 +19,10 @@ apk add --no-cache msmtp mailx >/dev/null 2>&1 || apk add --no-cache msmtp
mkdir -p /etc/androidcast mkdir -p /etc/androidcast
umask 077 umask 077
printf '%s\n' "$PASS" > /etc/androidcast/msmtp.secret printf '%s\n' "$PASS" > /etc/androidcast/msmtp.secret
chmod 600 /etc/androidcast/msmtp.secret NGROUP="$(getent group nginx 2>/dev/null | cut -d: -f1)"
NGROUP="${NGROUP:-nginx}"
chown "root:${NGROUP}" /etc/androidcast/msmtp.secret
chmod 640 /etc/androidcast/msmtp.secret
sed -e "s|__MSMTP_FROM__|${FROM}|g" -e "s|__MSMTP_USER__|${USER}|g" \ sed -e "s|__MSMTP_FROM__|${FROM}|g" -e "s|__MSMTP_USER__|${USER}|g" \
"$ROOT/mail/msmtprc.template" > /etc/msmtprc "$ROOT/mail/msmtprc.template" > /etc/msmtprc