mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 02:57:38 +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:
@@ -19,6 +19,19 @@ if ($action === 'verify') {
|
||||
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'] ?? '');
|
||||
$password = (string) ($data['password'] ?? '');
|
||||
$username = (string) ($data['username'] ?? '');
|
||||
|
||||
@@ -41,7 +41,7 @@ function hub_docs_section_html(string $section): string
|
||||
{
|
||||
switch ($section) {
|
||||
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':
|
||||
return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>';
|
||||
case 'deploy-prep':
|
||||
|
||||
@@ -375,6 +375,37 @@ a:hover { text-decoration: underline; }
|
||||
left: 10px;
|
||||
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-issue-title { font-weight: 600; }
|
||||
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
|
||||
@@ -1561,20 +1592,62 @@ button.report-tag--filter:hover {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tag-field input[type="text"] {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
.tag-field input[type="text"],
|
||||
.tag-field input[type="url"],
|
||||
.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);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
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-preset-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"nav.access_desc": "Roles, privilege sets, and operators.",
|
||||
"nav.builder": "Builder",
|
||||
"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.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
@@ -135,8 +137,7 @@
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
"login.submit": "Sign in",
|
||||
"login.hint_default": "Default: admin / admin",
|
||||
"login.register": "Register",
|
||||
"login.register": "Create account",
|
||||
"login.register_soon": "(coming soon)",
|
||||
"login.error": "Invalid credentials",
|
||||
"register.title": "Create account",
|
||||
@@ -146,6 +147,10 @@
|
||||
"register.password": "Password",
|
||||
"register.submit": "Register",
|
||||
"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.ok": "Your email is verified. You can sign in and enroll two-factor authentication.",
|
||||
"verify.fail": "Invalid or expired link.",
|
||||
@@ -153,6 +158,11 @@
|
||||
"verify.register_again": "Register again",
|
||||
"twofa.title": "Two-factor authentication",
|
||||
"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.submit": "Continue",
|
||||
"twofa.cancel": "Cancel",
|
||||
@@ -167,7 +177,7 @@
|
||||
"security.remove_totp": "Remove authenticator",
|
||||
"security.back_console": "Back to console",
|
||||
"nav.security": "Security",
|
||||
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||
"footer.copyright": "{holder}, {year}",
|
||||
"tickets.title": "Tickets",
|
||||
"issues.new": "New issue",
|
||||
"tickets.new": "New ticket",
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"nav.access_desc": "Роли, наборы привилегий и операторы.",
|
||||
"nav.builder": "Сборщик",
|
||||
"nav.builder_desc": "Docker CI для сборки APK и OTA.",
|
||||
"nav.monitor": "Мониторинг",
|
||||
"nav.monitor_desc": "Дашборды Grafana — состояние кластера, алерты, экспортёры.",
|
||||
"theme.label": "Тема",
|
||||
"theme.dark": "Тёмная",
|
||||
"theme.light": "Светлая",
|
||||
@@ -144,8 +146,7 @@
|
||||
"login.username": "Имя пользователя",
|
||||
"login.password": "Пароль",
|
||||
"login.submit": "Войти",
|
||||
"login.hint_default": "По умолчанию: admin / admin",
|
||||
"login.register": "Регистрация",
|
||||
"login.register": "Создать аккаунт",
|
||||
"login.register_soon": "(скоро)",
|
||||
"login.error": "Неверные учётные данные",
|
||||
"register.title": "Создать аккаунт",
|
||||
@@ -155,6 +156,10 @@
|
||||
"register.password": "Пароль",
|
||||
"register.submit": "Зарегистрироваться",
|
||||
"register.back_login": "Назад ко входу",
|
||||
"register.pending_title": "Проверьте почту",
|
||||
"register.pending_hint": "Мы отправили ссылку для подтверждения. Подтвердите email перед входом.",
|
||||
"register.countdown_label": "Ссылка действует",
|
||||
"register.resend": "Отправить письмо повторно",
|
||||
"verify.title": "Подтверждение email",
|
||||
"verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.",
|
||||
"verify.fail": "Недействительная или просроченная ссылка.",
|
||||
@@ -162,6 +167,11 @@
|
||||
"verify.register_again": "Зарегистрироваться снова",
|
||||
"twofa.title": "Двухфакторная аутентификация",
|
||||
"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.submit": "Продолжить",
|
||||
"twofa.cancel": "Отмена",
|
||||
@@ -176,7 +186,7 @@
|
||||
"security.remove_totp": "Отключить аутентификатор",
|
||||
"security.back_console": "Назад в консоль",
|
||||
"nav.security": "Безопасность",
|
||||
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||
"footer.copyright": "{holder}, {year}",
|
||||
"nav.tickets": "Тикеты",
|
||||
"ticket.lifecycle": "Жизненный цикл",
|
||||
"ticket.assignees": "Исполнители",
|
||||
|
||||
131
sim/cluster0/lab-seeds/backend/public/assets/js/register.js
Normal file
131
sim/cluster0/lab-seeds/backend/public/assets/js/register.js
Normal 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -209,6 +209,8 @@ if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$out = AuthRegistration::register($email, $password, $username);
|
||||
if ($out['ok']) {
|
||||
$registerSuccess = 'Check your email for a verification link.';
|
||||
$registerPending = true;
|
||||
$registerExpiresAt = (string) ($out['expires_at'] ?? '');
|
||||
require __DIR__ . '/../views/register.php';
|
||||
exit;
|
||||
}
|
||||
@@ -217,6 +219,7 @@ if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
'weak_password' => 'Password must be at least 10 characters.',
|
||||
'already_registered' => 'An account with this email or username already exists.',
|
||||
'rate_limited' => 'Too many attempts — try again later.',
|
||||
'mail_failed' => 'Could not send verification email — try again or contact support.',
|
||||
default => 'Registration failed.',
|
||||
};
|
||||
require __DIR__ . '/../views/register.php';
|
||||
|
||||
@@ -28,7 +28,7 @@ final class AuthMailer {
|
||||
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';
|
||||
$shortUrl = $verifyUrl;
|
||||
$qrImageUrl = null;
|
||||
@@ -49,7 +49,7 @@ final class AuthMailer {
|
||||
$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";
|
||||
$bodyHtml = null;
|
||||
$inline = [];
|
||||
@@ -70,7 +70,7 @@ final class AuthMailer {
|
||||
$cid = 'verify-qr-' . bin2hex(random_bytes(4)) . '@androidcast';
|
||||
$escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$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>Or scan this QR code on your phone:</p>'
|
||||
. '<p><img src="cid:' . $cid . '" alt="Verify email QR code" width="256" height="256"></p>'
|
||||
|
||||
@@ -3,8 +3,6 @@ declare(strict_types=1);
|
||||
|
||||
/** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */
|
||||
final class AuthRegistration {
|
||||
private const TOKEN_TTL_S = 86400;
|
||||
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
@@ -12,7 +10,12 @@ final class AuthRegistration {
|
||||
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 {
|
||||
self::ensureSchema();
|
||||
if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) {
|
||||
@@ -32,9 +35,14 @@ final class AuthRegistration {
|
||||
$username = strstr($emailNorm, '@', true) ?: $emailNorm;
|
||||
}
|
||||
$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]);
|
||||
if ($st->fetchColumn()) {
|
||||
$existing = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if ($existing) {
|
||||
if (($existing['status'] ?? '') === 'pending') {
|
||||
return self::resendVerification($emailNorm);
|
||||
}
|
||||
AuthAttempts::record('register_fail', $username);
|
||||
return ['ok' => false, 'error' => 'already_registered'];
|
||||
}
|
||||
@@ -49,21 +57,117 @@ final class AuthRegistration {
|
||||
'pending',
|
||||
]);
|
||||
$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));
|
||||
$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(
|
||||
'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]);
|
||||
$base = rtrim((string) cfg('base_path', ''), '/');
|
||||
$origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/');
|
||||
$verifyUrl = $origin . $base . '/verify-email?token=' . urlencode($token);
|
||||
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) {
|
||||
$verifyUrl = $origin . Auth::authUrl('/verify-email') . '?token=' . urlencode($token);
|
||||
$ttlLabel = self::formatTtlLabel($ttl);
|
||||
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl, $ttlLabel)) {
|
||||
error_log('AuthRegistration: verify mail failed for ' . $emailNorm);
|
||||
return ['ok' => false, 'error' => 'mail_failed'];
|
||||
}
|
||||
AuthAttempts::record('register_ok', $username);
|
||||
return ['ok' => true];
|
||||
return ['ok' => true, 'expires_at' => $expires];
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string} */
|
||||
@@ -96,7 +200,39 @@ final class AuthRegistration {
|
||||
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 {
|
||||
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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
* 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>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
@@ -25,8 +31,6 @@
|
||||
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
|
||||
})();
|
||||
</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">
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
|
||||
@@ -51,6 +55,19 @@
|
||||
<?php if (($view ?? '') === 'rbac'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
|
||||
<?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'); ?>
|
||||
</head>
|
||||
<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('short_links_operate') ? ' data-can-sl-operate="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>
|
||||
<div class="shell<?= ($view ?? '') === 'graphs' ? ' shell--graphs-full' : '' ?>">
|
||||
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
|
||||
<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">
|
||||
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="nav-list">
|
||||
<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>
|
||||
<?php platform_render_nav_shell([
|
||||
'project_base' => $__navProjectBase,
|
||||
'issues_base' => $__navIssuesBase,
|
||||
'active' => $__navActive,
|
||||
'username' => $__navUser,
|
||||
]); ?>
|
||||
<main class="main-pane">
|
||||
<?php if (($view ?? 'home') === 'home'): ?>
|
||||
<?php require __DIR__ . '/partials/console_home_landing.php'; ?>
|
||||
@@ -607,6 +452,91 @@
|
||||
</div>
|
||||
</section>
|
||||
</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'): ?>
|
||||
<div id="short-links-app" class="reports-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$bp = Auth::basePath();
|
||||
$projectBase = Auth::projectBasePath();
|
||||
$issuesBase = rtrim((string) cfg('links.crashes', $projectBase . '/issues'), '/');
|
||||
$navUser = trim((string) (Auth::user()['username'] ?? ''));
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
@@ -12,38 +15,49 @@ $bp = Auth::basePath();
|
||||
(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($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>
|
||||
<?php AnalyticsHead::render('live_education'); ?>
|
||||
<script src="<?= h($bp) ?>/assets/js/live_cast_webrtc.js" defer></script>
|
||||
<script src="<?= h($bp) ?>/assets/js/live_education.js" defer></script>
|
||||
</head>
|
||||
<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(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">
|
||||
<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 id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p>
|
||||
<p class="live-timer-label">Time left: <strong id="edu-timer">5:00</strong></p>
|
||||
<video id="edu-preview" class="live-preview" playsinline muted autoplay hidden></video>
|
||||
<div id="edu-share" class="live-share card card--lift" hidden></div>
|
||||
<pre id="edu-stats" class="live-stats-nerds" hidden aria-live="polite"></pre>
|
||||
<div class="live-actions">
|
||||
<button type="button" class="btn btn--primary" id="edu-start">Start screen share</button>
|
||||
<button type="button" class="btn" id="edu-stop">Stop</button>
|
||||
<button type="button" class="btn btn--ghost" id="edu-notify">Enable notifications</button>
|
||||
</div>
|
||||
<p class="muted graphs-footnote">
|
||||
WebRTC P2P (no SFU): share the viewer link or QR. Same REST signaling API supports future mobile cast to browser on LAN.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<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">
|
||||
<span class="tag-pill">Education demo</span>
|
||||
</header>
|
||||
<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 id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p>
|
||||
<p class="live-timer-label">Time left: <strong id="edu-timer">5:00</strong></p>
|
||||
<video id="edu-preview" class="live-preview" playsinline muted autoplay hidden></video>
|
||||
<div id="edu-share" class="live-share card card--lift" hidden></div>
|
||||
<pre id="edu-stats" class="live-stats-nerds" hidden aria-live="polite"></pre>
|
||||
<div class="live-actions">
|
||||
<button type="button" class="btn btn--primary" id="edu-start">Start screen share</button>
|
||||
<button type="button" class="btn" id="edu-stop">Stop</button>
|
||||
<button type="button" class="btn btn--ghost" id="edu-notify">Enable notifications</button>
|
||||
</div>
|
||||
<p class="muted graphs-footnote">
|
||||
WebRTC P2P (no SFU): share the viewer link or QR. Same REST signaling API supports future mobile cast to browser on LAN.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||
<?php platform_render_footer(); ?>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
$bp = Auth::basePath();
|
||||
$auth = Auth::authUrl();
|
||||
$registerPending = !empty($registerPending);
|
||||
$registerExpiresAt = (string) ($registerExpiresAt ?? '');
|
||||
$registerEmail = (string) ($registerEmail ?? '');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
@@ -16,11 +19,18 @@ $auth = Auth::authUrl();
|
||||
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
|
||||
})();
|
||||
</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">
|
||||
<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'); ?>
|
||||
</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">
|
||||
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
|
||||
<span class="locale-flag" aria-hidden="true">🇬🇧</span>
|
||||
@@ -30,17 +40,14 @@ $auth = Auth::authUrl();
|
||||
</select>
|
||||
</label>
|
||||
</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>
|
||||
<p class="muted" data-i18n="register.hint">We will email a verification link before you can sign in.</p>
|
||||
<?php if (!empty($registerError)): ?>
|
||||
<div class="alert"><?= h($registerError) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($registerSuccess)): ?>
|
||||
<div class="alert alert--ok"><?= h($registerSuccess) ?></div>
|
||||
<?php endif; ?>
|
||||
<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>
|
||||
<input name="username" autocomplete="username" value="<?= h($registerUsername ?? '') ?>"></label>
|
||||
<label><span data-i18n="register.password">Password</span>
|
||||
@@ -48,6 +55,22 @@ $auth = Auth::authUrl();
|
||||
<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>
|
||||
</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(); ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
61
sim/cluster0/lab-seeds/builder-layout.php.reference
Normal file
61
sim/cluster0/lab-seeds/builder-layout.php.reference
Normal 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>
|
||||
Reference in New Issue
Block a user