mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:39:09 +03:00
be sync
This commit is contained in:
@@ -5,8 +5,11 @@ public final class BackendEndpoints {
|
|||||||
public static final String APPS_HOST = "apps.f0xx.org";
|
public static final String APPS_HOST = "apps.f0xx.org";
|
||||||
public static final String CRASHES_BASE =
|
public static final String CRASHES_BASE =
|
||||||
"https://" + APPS_HOST + "/app/androidcast_project/crashes";
|
"https://" + APPS_HOST + "/app/androidcast_project/crashes";
|
||||||
|
public static final String GRAPHS_BASE =
|
||||||
|
"https://" + APPS_HOST + "/app/androidcast_project/graphs";
|
||||||
public static final String HEARTBEAT_URL = CRASHES_BASE + "/api/heartbeat.php";
|
public static final String HEARTBEAT_URL = CRASHES_BASE + "/api/heartbeat.php";
|
||||||
public static final String UPLOAD_URL = CRASHES_BASE + "/api/upload.php";
|
public static final String UPLOAD_URL = CRASHES_BASE + "/api/upload.php";
|
||||||
|
public static final String GRAPH_UPLOAD_URL = GRAPHS_BASE + "/api/graph_upload.php";
|
||||||
|
|
||||||
private BackendEndpoints() {}
|
private BackendEndpoints() {}
|
||||||
|
|
||||||
@@ -26,4 +29,16 @@ public final class BackendEndpoints {
|
|||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maps a crashes upload URL to the graphs session ingest endpoint. */
|
||||||
|
public static String graphUploadUrlFromCrashesUrl(String crashesUrl) {
|
||||||
|
if (crashesUrl == null || crashesUrl.isEmpty()) {
|
||||||
|
return GRAPH_UPLOAD_URL;
|
||||||
|
}
|
||||||
|
String trimmed = normalizeCrashesUrl(crashesUrl.trim());
|
||||||
|
if (trimmed.contains("/crashes/")) {
|
||||||
|
return trimmed.replace("/crashes/", "/graphs/").replace("/api/upload.php", "/api/graph_upload.php");
|
||||||
|
}
|
||||||
|
return GRAPH_UPLOAD_URL;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.foxx.androidcast.stats;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.AppPreferences;
|
||||||
|
import com.foxx.androidcast.BuildConfig;
|
||||||
|
import com.foxx.androidcast.DeviceInfo;
|
||||||
|
import com.foxx.androidcast.crash.CrashSettingsStore;
|
||||||
|
import com.foxx.androidcast.crash.CrashUploadClient;
|
||||||
|
import com.foxx.androidcast.network.BackendEndpoints;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/** POST anonymous session aggregates to the graphs ingest API ({@code graph_upload.php}). */
|
||||||
|
public final class GraphSessionUploader {
|
||||||
|
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "graph-session-upload");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
private GraphSessionUploader() {}
|
||||||
|
|
||||||
|
public static void uploadAsync(Context context, JSONObject sessionRoot) {
|
||||||
|
if (context == null || sessionRoot == null || !AppPreferences.isGrabSessionStats(context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Context app = context.getApplicationContext();
|
||||||
|
EXEC.execute(() -> upload(app, sessionRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean upload(Context context, JSONObject sessionRoot) {
|
||||||
|
try {
|
||||||
|
JSONObject payload = toGraphPayload(context, sessionRoot);
|
||||||
|
if (payload == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String url = BackendEndpoints.graphUploadUrlFromCrashesUrl(
|
||||||
|
CrashSettingsStore.load(context).uploadUrl);
|
||||||
|
return CrashUploadClient.upload(url, payload);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static JSONObject toGraphPayload(Context context, JSONObject root) throws Exception {
|
||||||
|
long started = root.optLong("started_at_epoch_ms", 0L);
|
||||||
|
long ended = root.optLong("ended_at_epoch_ms", 0L);
|
||||||
|
if (started <= 0 || ended <= 0 || ended < started) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String direction = root.optString("direction", "send");
|
||||||
|
String ingestDir = "recv".equalsIgnoreCase(direction) ? "receiver" : "sender";
|
||||||
|
String transport = root.optString("transport", "wifi").toLowerCase(Locale.US);
|
||||||
|
if (transport.isEmpty()) {
|
||||||
|
transport = "wifi";
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject payload = new JSONObject();
|
||||||
|
payload.put("schema_version", 1);
|
||||||
|
payload.put("ingest_kind", "graph_session");
|
||||||
|
payload.put("session_id", sessionId(started, direction));
|
||||||
|
payload.put("device_id", DeviceInfo.getDeviceUuid(context));
|
||||||
|
payload.put("app_version", BuildConfig.VERSION_NAME);
|
||||||
|
payload.put("sdk_int", Build.VERSION.SDK_INT);
|
||||||
|
payload.put("started_at_epoch_ms", started);
|
||||||
|
payload.put("ended_at_epoch_ms", ended);
|
||||||
|
payload.put("duration_s", Math.max(0, (int) ((ended - started) / 1000L)));
|
||||||
|
payload.put("direction", ingestDir);
|
||||||
|
payload.put("transport", transport);
|
||||||
|
payload.put("install_source", "direct");
|
||||||
|
payload.put("ntp_source", AppPreferences.isDevBackendNtpSyncEnabled(context) ? "backend" : "device");
|
||||||
|
|
||||||
|
if (root.has("avg_video_bitrate_kbps")) {
|
||||||
|
payload.put("avg_kbps", Math.max(0, (int) Math.round(root.optDouble("avg_video_bitrate_kbps", 0))));
|
||||||
|
}
|
||||||
|
if (root.has("avg_loss_ratio")) {
|
||||||
|
payload.put("packet_loss_pct", Math.max(0.0, root.optDouble("avg_loss_ratio", 0) * 100.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject globals = root.optJSONObject("globals");
|
||||||
|
String reason = globals != null ? globals.optString("reason", "") : "";
|
||||||
|
payload.put("completed", reason.isEmpty() || !"crash".equalsIgnoreCase(reason));
|
||||||
|
|
||||||
|
JSONObject meta = new JSONObject();
|
||||||
|
meta.put("capture_mode", root.optString("capture_mode", ""));
|
||||||
|
meta.put("video_codec", root.optString("video_codec", ""));
|
||||||
|
meta.put("role", root.optString("role", ""));
|
||||||
|
if (root.has("avg_encode_fps")) {
|
||||||
|
meta.put("avg_encode_fps", root.optDouble("avg_encode_fps", 0));
|
||||||
|
}
|
||||||
|
payload.put("meta", meta);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sessionId(long startedMs, String direction) {
|
||||||
|
String seed = startedMs + ":" + direction + ":" + UUID.randomUUID();
|
||||||
|
return "mob-" + Integer.toHexString(seed.hashCode()) + "-" + startedMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,6 +274,7 @@ public final class SessionStatsRecorder {
|
|||||||
try {
|
try {
|
||||||
JSONObject root = buildSessionJson(endedMs, reason);
|
JSONObject root = buildSessionJson(endedMs, reason);
|
||||||
SessionStatsStore.saveSession(context, startedMs, direction, root);
|
SessionStatsStore.saveSession(context, startedMs, direction, root);
|
||||||
|
GraphSessionUploader.uploadAsync(context, root);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,10 @@ public class BackendEndpointsTest {
|
|||||||
String legacy = "https://f0xx.org/app/androidcast_project/crashes/api/upload.php";
|
String legacy = "https://f0xx.org/app/androidcast_project/crashes/api/upload.php";
|
||||||
assertEquals(BackendEndpoints.UPLOAD_URL, BackendEndpoints.normalizeCrashesUrl(legacy));
|
assertEquals(BackendEndpoints.UPLOAD_URL, BackendEndpoints.normalizeCrashesUrl(legacy));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void graphUploadUrlFromCrashesUrl() {
|
||||||
|
assertEquals(BackendEndpoints.GRAPH_UPLOAD_URL,
|
||||||
|
BackendEndpoints.graphUploadUrlFromCrashesUrl(BackendEndpoints.UPLOAD_URL));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
examples/crash_reporter/backend/public/api/ticket_create.php
Normal file
65
examples/crash_reporter/backend/public/api/ticket_create.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if (!Auth::user()) {
|
||||||
|
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw !== false ? $raw : '', true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
$title = trim((string) ($data['title'] ?? ''));
|
||||||
|
$body = trim((string) ($data['body'] ?? ''));
|
||||||
|
$brief = trim((string) ($data['brief'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
json_out(['ok' => false, 'error' => 'title required'], 400);
|
||||||
|
}
|
||||||
|
if ($body === '' && $brief !== '') {
|
||||||
|
$body = $brief;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = is_array($data['tags'] ?? null) ? $data['tags'] : [];
|
||||||
|
if ($tags === []) {
|
||||||
|
$tags = [['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = (int) round(microtime(true) * 1000);
|
||||||
|
$payload = [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'ingest_kind' => 'ticket',
|
||||||
|
'ticket_id' => 'web-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)),
|
||||||
|
'title' => mb_substr($title, 0, 256),
|
||||||
|
'brief' => $brief !== '' ? mb_substr($brief, 0, 512) : mb_substr($title, 0, 512),
|
||||||
|
'body' => $body !== '' ? $body : $title,
|
||||||
|
'opened_at_epoch_ms' => $now,
|
||||||
|
'rating' => max(0, min(5, (int) ($data['rating'] ?? 3))),
|
||||||
|
'owner' => (string) ($user['username'] ?? 'admin'),
|
||||||
|
'tags' => $tags,
|
||||||
|
'device' => [
|
||||||
|
'manufacturer' => 'web',
|
||||||
|
'model' => 'console',
|
||||||
|
'sdk_int' => 0,
|
||||||
|
'release' => 'n/a',
|
||||||
|
],
|
||||||
|
'app' => [
|
||||||
|
'package' => 'com.foxx.androidcast',
|
||||||
|
'version_name' => 'console',
|
||||||
|
'version_code' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database::requireTicketsTable();
|
||||||
|
TicketRepository::insert($payload);
|
||||||
|
json_out(['ok' => true, 'ticket_id' => $payload['ticket_id']]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('ticket_create: ' . $e->getMessage());
|
||||||
|
json_out(['ok' => false, 'error' => 'create failed'], 500);
|
||||||
|
}
|
||||||
@@ -38,7 +38,14 @@ body {
|
|||||||
}
|
}
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
.shell { display: flex; min-height: calc(100vh - 40px); flex: 1 0 auto; }
|
.shell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: calc(100vh - var(--footer-h, 40px));
|
||||||
|
max-height: calc(100vh - var(--footer-h, 40px));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
.shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
.shell.shell--graphs-full .main-pane {
|
.shell.shell--graphs-full .main-pane {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -53,12 +60,16 @@ a:hover { text-decoration: underline; }
|
|||||||
}
|
}
|
||||||
.nav-pane {
|
.nav-pane {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
transition: width .22s ease;
|
transition: width .22s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.nav-pane.open { width: 200px; }
|
.nav-pane.open { width: 200px; }
|
||||||
.nav-handle {
|
.nav-handle {
|
||||||
@@ -94,6 +105,9 @@ a:hover { text-decoration: underline; }
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 56px 0 0;
|
margin: 56px 0 0;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.nav-list li { margin: 6px 0; }
|
.nav-list li { margin: 6px 0; }
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@@ -394,16 +408,14 @@ a:hover { text-decoration: underline; }
|
|||||||
border-left: 7px solid currentColor;
|
border-left: 7px solid currentColor;
|
||||||
}
|
}
|
||||||
.nav-user {
|
.nav-user {
|
||||||
position: absolute;
|
position: static;
|
||||||
bottom: 10px;
|
flex-shrink: 0;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 0 8px;
|
padding: 8px 8px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.nav-pane.open .nav-user {
|
.nav-pane.open .nav-user {
|
||||||
@@ -434,9 +446,17 @@ a:hover { text-decoration: underline; }
|
|||||||
}
|
}
|
||||||
.nav-link--logout { color: var(--muted); }
|
.nav-link--logout { color: var(--muted); }
|
||||||
.nav-link--logout:hover { color: var(--danger); background: rgba(248, 113, 113, .12); }
|
.nav-link--logout:hover { color: var(--danger); background: rgba(248, 113, 113, .12); }
|
||||||
.main-pane { flex: 1; padding: 24px 28px; }
|
.main-pane {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
body { --footer-h: 40px; }
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
height: 40px;
|
flex-shrink: 0;
|
||||||
|
height: var(--footer-h);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -674,6 +694,8 @@ a:hover { text-decoration: underline; }
|
|||||||
.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
|
.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
|
||||||
.nav-locale {
|
.nav-locale {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
padding: 8px 10px 6px;
|
padding: 8px 10px 6px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -1710,6 +1732,59 @@ button.report-tag--filter:hover {
|
|||||||
color: var(--muted, #94a3b8);
|
color: var(--muted, #94a3b8);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
.graphs-grid .card.card--lift.graph-brick--empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.graphs-grid .card.card--lift.graph-brick--dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
outline: 2px dashed var(--accent);
|
||||||
|
}
|
||||||
|
.graph-brick-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
.graph-brick-tip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.modal-dialog {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 32rem;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
.modal-dialog::backdrop { background: rgba(8, 12, 20, 0.72); }
|
||||||
|
.modal-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
}
|
||||||
|
.modal-form label { display: flex; flex-direction: column; gap: 6px; font-size: 0.9rem; }
|
||||||
|
.modal-form input,
|
||||||
|
.modal-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
|
||||||
.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; min-height: 120px; }
|
.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; min-height: 120px; }
|
||||||
.graph-detail-overlay {
|
.graph-detail-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -146,6 +146,12 @@
|
|||||||
"nav.security": "Security",
|
"nav.security": "Security",
|
||||||
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||||
"tickets.title": "Tickets",
|
"tickets.title": "Tickets",
|
||||||
|
"tickets.new": "New ticket",
|
||||||
|
"ticket.create": "Create",
|
||||||
|
"ticket.cancel": "Cancel",
|
||||||
|
"ticket.title": "Title",
|
||||||
|
"ticket.brief": "Brief",
|
||||||
|
"ticket.body": "Description",
|
||||||
"tickets.empty": "No tickets yet.",
|
"tickets.empty": "No tickets yet.",
|
||||||
"tickets.col_issue": "Issue",
|
"tickets.col_issue": "Issue",
|
||||||
"tickets.col_opened": "Opened",
|
"tickets.col_opened": "Opened",
|
||||||
|
|||||||
@@ -95,13 +95,30 @@
|
|||||||
return graphScopes.platform;
|
return graphScopes.platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BRICK_ORDER_KEY = 'graphs_brick_order_v1';
|
||||||
|
|
||||||
|
function prepareCanvas(canvas, fallbackW, fallbackH) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const w = Math.max(280, Math.round(rect.width || fallbackW || canvas.width || 520));
|
||||||
|
const h = Math.max(120, Math.round(rect.height || fallbackH || canvas.height || 160));
|
||||||
|
const dpr = Math.min(2.5, window.devicePixelRatio || 1);
|
||||||
|
canvas.width = Math.round(w * dpr);
|
||||||
|
canvas.height = Math.round(h * dpr);
|
||||||
|
canvas.style.width = w + 'px';
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
return { w, h, dpr };
|
||||||
|
}
|
||||||
|
|
||||||
function drawLineChart(canvas, series, color, opts) {
|
function drawLineChart(canvas, series, color, opts) {
|
||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
const interactive = opts && opts.interactive;
|
const interactive = opts && opts.interactive;
|
||||||
|
const sized = prepareCanvas(canvas, 520, interactive ? 380 : 160);
|
||||||
|
const w = sized.w;
|
||||||
|
const h = sized.h;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
const w = canvas.width;
|
|
||||||
const h = canvas.height;
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
@@ -196,6 +213,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const highlightIdx = opts && opts.highlightIdx;
|
||||||
|
if (highlightIdx != null && plotPoints[highlightIdx]) {
|
||||||
|
const hi = plotPoints[highlightIdx];
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = '#f8fafc';
|
||||||
|
ctx.strokeStyle = lineColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.arc(hi.x, hi.y, 7, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points: plotPoints,
|
points: plotPoints,
|
||||||
padL,
|
padL,
|
||||||
@@ -255,10 +284,11 @@
|
|||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
const interactive = opts && opts.interactive;
|
const interactive = opts && opts.interactive;
|
||||||
const highlightIdx = opts && opts.highlightIdx;
|
const highlightIdx = opts && opts.highlightIdx;
|
||||||
|
const sized = prepareCanvas(canvas, interactive ? 640 : 520, interactive ? 380 : 160);
|
||||||
|
const w = sized.w;
|
||||||
|
const h = sized.h;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
const w = canvas.width;
|
|
||||||
const h = canvas.height;
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
||||||
@@ -420,9 +450,13 @@
|
|||||||
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
|
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
|
||||||
const need = section.getAttribute('data-graph-scope');
|
const need = section.getAttribute('data-graph-scope');
|
||||||
let show = false;
|
let show = false;
|
||||||
if (need === 'user') show = true;
|
if (viewer === 'platform_admin') {
|
||||||
if (need === 'slug_admin' && (viewer === 'slug_admin' || viewer === 'platform_admin')) show = true;
|
show = need === 'platform_admin';
|
||||||
if (need === 'platform_admin' && viewer === 'platform_admin') show = true;
|
} else if (viewer === 'slug_admin') {
|
||||||
|
show = need === 'slug_admin' || need === 'user';
|
||||||
|
} else {
|
||||||
|
show = need === 'user';
|
||||||
|
}
|
||||||
section.hidden = !show;
|
section.hidden = !show;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -445,6 +479,209 @@
|
|||||||
card.setAttribute('role', 'button');
|
card.setAttribute('role', 'button');
|
||||||
const title = card.querySelector('h3');
|
const title = card.querySelector('h3');
|
||||||
if (title) card.setAttribute('aria-label', 'Open full view: ' + title.textContent);
|
if (title) card.setAttribute('aria-label', 'Open full view: ' + title.textContent);
|
||||||
|
if (canvas && canvas.id) {
|
||||||
|
card.setAttribute('data-brick-id', canvas.id.replace(/^graph-/, ''));
|
||||||
|
} else if (breakdown && breakdown.id) {
|
||||||
|
card.setAttribute('data-brick-id', breakdown.id.replace(/^graph-/, ''));
|
||||||
|
} else if (kpi && kpi.id) {
|
||||||
|
card.setAttribute('data-brick-id', kpi.id.replace(/^graph-/, ''));
|
||||||
|
}
|
||||||
|
if (title && !title.querySelector('.graph-brick-drag-handle')) {
|
||||||
|
const handle = document.createElement('span');
|
||||||
|
handle.className = 'graph-brick-drag-handle';
|
||||||
|
handle.textContent = '⋮⋮';
|
||||||
|
handle.title = 'Drag to reorder';
|
||||||
|
handle.setAttribute('draggable', 'true');
|
||||||
|
title.appendChild(handle);
|
||||||
|
}
|
||||||
|
if (canvas && !card.querySelector('.graph-brick-tip')) {
|
||||||
|
const tip = document.createElement('p');
|
||||||
|
tip.className = 'graph-brick-tip';
|
||||||
|
tip.hidden = true;
|
||||||
|
card.style.position = 'relative';
|
||||||
|
card.appendChild(tip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldHasData(def, scope) {
|
||||||
|
if (!def || !scope) return false;
|
||||||
|
const field = scope[def.field];
|
||||||
|
if (def.type === 'line') {
|
||||||
|
return Array.isArray(field) && field.some((p) => Number(p.y || 0) > 0);
|
||||||
|
}
|
||||||
|
if (def.type === 'pie' || def.type === 'breakdown' || def.type === 'toplist') {
|
||||||
|
return Array.isArray(field) && field.some((r) => Number(r.value || 0) > 0);
|
||||||
|
}
|
||||||
|
if (def.type === 'kpi') {
|
||||||
|
return field != null && field !== '' && String(field) !== '—';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEmptyBricks() {
|
||||||
|
document.querySelectorAll('.graphs-grid .card.card--lift').forEach((card) => {
|
||||||
|
const canvas = card.querySelector('canvas');
|
||||||
|
const breakdown = card.querySelector('.graph-breakdown');
|
||||||
|
const kpi = card.querySelector('.graph-kpi');
|
||||||
|
let empty = true;
|
||||||
|
const brickId = card.getAttribute('data-brick-id') || '';
|
||||||
|
const m = brickId.match(/^(user|slug|platform)-(.+)$/);
|
||||||
|
if (m) {
|
||||||
|
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
|
||||||
|
const def = BRICK_DEFS[m[2]];
|
||||||
|
empty = !fieldHasData(def, scopeData(scopeKey));
|
||||||
|
} else if (canvas && canvas.id) {
|
||||||
|
const parsed = parseCanvasId(canvas.id);
|
||||||
|
empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey));
|
||||||
|
} else if (breakdown) {
|
||||||
|
empty = breakdown.textContent.trim() === 'No data' || breakdown.innerHTML.trim() === '';
|
||||||
|
} else if (kpi) {
|
||||||
|
empty = (kpi.textContent || '').trim() === '—';
|
||||||
|
}
|
||||||
|
card.classList.toggle('graph-brick--empty', empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionMetricsDeficit(scope) {
|
||||||
|
if (!scope) return true;
|
||||||
|
const sessionFields = [
|
||||||
|
'sessions_per_day',
|
||||||
|
'unique_devices_per_day',
|
||||||
|
'avg_duration_s_per_day',
|
||||||
|
'send_sessions_per_day',
|
||||||
|
'recv_sessions_per_day',
|
||||||
|
'avg_bitrate_kbps_per_day',
|
||||||
|
];
|
||||||
|
return !sessionFields.some((key) => {
|
||||||
|
const rows = scope[key];
|
||||||
|
return Array.isArray(rows) && rows.some((p) => Number(p.y || 0) > 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBrickOrder(scopeKey) {
|
||||||
|
try {
|
||||||
|
const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}');
|
||||||
|
return Array.isArray(all[scopeKey]) ? all[scopeKey] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBrickOrder(scopeKey, ids) {
|
||||||
|
try {
|
||||||
|
const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}');
|
||||||
|
all[scopeKey] = ids;
|
||||||
|
localStorage.setItem(BRICK_ORDER_KEY, JSON.stringify(all));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBrickOrder() {
|
||||||
|
document.querySelectorAll('.graphs-scope').forEach((section) => {
|
||||||
|
if (section.hidden) return;
|
||||||
|
const scopeKey = section.getAttribute('data-graph-scope') || 'user';
|
||||||
|
const grid = section.querySelector('.graphs-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
const order = readBrickOrder(scopeKey);
|
||||||
|
if (!order.length) return;
|
||||||
|
const cards = Array.from(grid.querySelectorAll('.card.card--lift'));
|
||||||
|
const byId = new Map();
|
||||||
|
cards.forEach((c) => {
|
||||||
|
const id = c.getAttribute('data-brick-id');
|
||||||
|
if (id) byId.set(id, c);
|
||||||
|
});
|
||||||
|
order.forEach((id) => {
|
||||||
|
const card = byId.get(id);
|
||||||
|
if (card) grid.appendChild(card);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let brickDragMoved = false;
|
||||||
|
|
||||||
|
function initBrickDragDrop() {
|
||||||
|
let dragCard = null;
|
||||||
|
document.querySelectorAll('.graphs-grid').forEach((grid) => {
|
||||||
|
grid.addEventListener('dragstart', (ev) => {
|
||||||
|
const handle = ev.target.closest('.graph-brick-drag-handle');
|
||||||
|
if (!handle) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = handle.closest('.card.card--lift');
|
||||||
|
if (!card || !grid.contains(card)) return;
|
||||||
|
dragCard = card;
|
||||||
|
brickDragMoved = false;
|
||||||
|
card.classList.add('graph-brick--dragging');
|
||||||
|
if (ev.dataTransfer) {
|
||||||
|
ev.dataTransfer.effectAllowed = 'move';
|
||||||
|
ev.dataTransfer.setData('text/plain', card.getAttribute('data-brick-id') || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
grid.addEventListener('dragend', () => {
|
||||||
|
if (dragCard) dragCard.classList.remove('graph-brick--dragging');
|
||||||
|
dragCard = null;
|
||||||
|
const section = grid.closest('.graphs-scope');
|
||||||
|
const scopeKey = section ? section.getAttribute('data-graph-scope') || 'user' : 'user';
|
||||||
|
const ids = Array.from(grid.querySelectorAll('.card.card--lift'))
|
||||||
|
.map((c) => c.getAttribute('data-brick-id'))
|
||||||
|
.filter(Boolean);
|
||||||
|
saveBrickOrder(scopeKey, ids);
|
||||||
|
});
|
||||||
|
grid.addEventListener('dragover', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!dragCard) return;
|
||||||
|
brickDragMoved = true;
|
||||||
|
const over = ev.target.closest('.card.card--lift');
|
||||||
|
if (!over || over === dragCard || !grid.contains(over)) return;
|
||||||
|
const rect = over.getBoundingClientRect();
|
||||||
|
const before = ev.clientY < rect.top + rect.height / 2;
|
||||||
|
grid.insertBefore(dragCard, before ? over : over.nextSibling);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachBrickPreviewHover() {
|
||||||
|
document.querySelectorAll('.graphs-grid .card.card--lift canvas').forEach((canvas) => {
|
||||||
|
const card = canvas.closest('.card');
|
||||||
|
const tip = card ? card.querySelector('.graph-brick-tip') : null;
|
||||||
|
if (!card || !tip) return;
|
||||||
|
const parsed = parseCanvasId(canvas.id);
|
||||||
|
if (!parsed || !parsed.def || parsed.def.type !== 'line') return;
|
||||||
|
const scope = scopeData(parsed.scopeKey);
|
||||||
|
if (!scope || !Array.isArray(scope[parsed.def.field])) return;
|
||||||
|
const series = scope[parsed.def.field];
|
||||||
|
const meta = drawLineChart(canvas, series, parsed.def.color, { interactive: false });
|
||||||
|
if (!meta || !meta.points.length) return;
|
||||||
|
canvas.onmousemove = function (ev) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = meta.w / rect.width;
|
||||||
|
const x = (ev.clientX - rect.left) * scaleX;
|
||||||
|
let best = null;
|
||||||
|
let bestD = Infinity;
|
||||||
|
meta.points.forEach((pt) => {
|
||||||
|
const d = Math.abs(pt.x - x);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = pt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!best || bestD > 32) {
|
||||||
|
tip.hidden = true;
|
||||||
|
drawLineChart(canvas, series, parsed.def.color, { interactive: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = best.raw || {};
|
||||||
|
tip.hidden = false;
|
||||||
|
tip.textContent = parsed.def.title + ': ' + String(raw.x || '') + ' → ' + Number(raw.y || 0);
|
||||||
|
tip.style.left = Math.min(card.clientWidth - 12, ev.clientX - card.getBoundingClientRect().left + 10) + 'px';
|
||||||
|
tip.style.top = Math.max(8, ev.clientY - card.getBoundingClientRect().top - 28) + 'px';
|
||||||
|
drawLineChart(canvas, series, parsed.def.color, { interactive: false, highlightIdx: best.idx });
|
||||||
|
};
|
||||||
|
canvas.onmouseleave = function () {
|
||||||
|
tip.hidden = true;
|
||||||
|
drawLineChart(canvas, series, parsed.def.color, { interactive: false });
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,7 +963,13 @@
|
|||||||
applyColumns(2);
|
applyColumns(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initBrickDragDrop();
|
||||||
|
|
||||||
app.addEventListener('click', (ev) => {
|
app.addEventListener('click', (ev) => {
|
||||||
|
if (ev.target.closest('.graph-brick-drag-handle') || brickDragMoved) {
|
||||||
|
brickDragMoved = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const card = ev.target.closest('.graph-brick--clickable');
|
const card = ev.target.closest('.graph-brick--clickable');
|
||||||
if (card && app.contains(card)) openGraphDetail(card);
|
if (card && app.contains(card)) openGraphDetail(card);
|
||||||
});
|
});
|
||||||
@@ -770,8 +1013,18 @@
|
|||||||
if (graphScopes.slug) renderScope('slug', graphScopes.slug);
|
if (graphScopes.slug) renderScope('slug', graphScopes.slug);
|
||||||
if (graphScopes.platform) renderScope('platform', graphScopes.platform);
|
if (graphScopes.platform) renderScope('platform', graphScopes.platform);
|
||||||
markGraphBricks();
|
markGraphBricks();
|
||||||
|
hideEmptyBricks();
|
||||||
|
applyBrickOrder();
|
||||||
|
attachBrickPreviewHover();
|
||||||
const cols = colsSel && colsSel.value ? colsSel.value : '2';
|
const cols = colsSel && colsSel.value ? colsSel.value : '2';
|
||||||
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols;
|
let statusText = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols;
|
||||||
|
const visibleScope =
|
||||||
|
viewer === 'platform_admin' ? graphScopes.platform : viewer === 'slug_admin' ? graphScopes.slug : graphScopes.user;
|
||||||
|
if (sessionMetricsDeficit(visibleScope)) {
|
||||||
|
statusText +=
|
||||||
|
' · Session metrics empty — cast with Developer “Grab session stats” on; app uploads graph_session to graph_upload.php';
|
||||||
|
}
|
||||||
|
status.textContent = statusText;
|
||||||
openGraphFromPermalink();
|
openGraphFromPermalink();
|
||||||
};
|
};
|
||||||
xhr.onerror = function () {
|
xhr.onerror = function () {
|
||||||
|
|||||||
@@ -349,6 +349,60 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newBtn = document.getElementById('tickets-new-btn');
|
||||||
|
const dialog = document.getElementById('ticket-create-dialog');
|
||||||
|
const form = document.getElementById('ticket-create-form');
|
||||||
|
const cancelBtn = document.getElementById('ticket-create-cancel');
|
||||||
|
const createStatus = document.getElementById('ticket-create-status');
|
||||||
|
if (newBtn && dialog && form) {
|
||||||
|
newBtn.addEventListener('click', () => {
|
||||||
|
if (createStatus) createStatus.textContent = '';
|
||||||
|
form.reset();
|
||||||
|
if (typeof dialog.showModal === 'function') {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', () => dialog.close());
|
||||||
|
}
|
||||||
|
form.addEventListener('submit', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const titleEl = document.getElementById('ticket-create-title');
|
||||||
|
const briefEl = document.getElementById('ticket-create-brief');
|
||||||
|
const bodyEl = document.getElementById('ticket-create-body');
|
||||||
|
const payload = {
|
||||||
|
title: titleEl && titleEl.value ? titleEl.value.trim() : '',
|
||||||
|
brief: briefEl && briefEl.value ? briefEl.value.trim() : '',
|
||||||
|
body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '',
|
||||||
|
};
|
||||||
|
if (!payload.title) return;
|
||||||
|
if (createStatus) createStatus.textContent = 'Creating…';
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', basePath() + '/api/ticket_create.php', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onload = function () {
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch {
|
||||||
|
if (createStatus) createStatus.textContent = 'Invalid response';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
if (createStatus) createStatus.textContent = (data && data.error) || 'Create failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialog.close();
|
||||||
|
page = 1;
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
xhr.onerror = function () {
|
||||||
|
if (createStatus) createStatus.textContent = 'Network error';
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ if ($route === '/api/ticket_upload.php' || str_ends_with($route, '/api/ticket_up
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/ticket_create.php' || str_ends_with($route, '/api/ticket_create.php')) {
|
||||||
|
require __DIR__ . '/api/ticket_create.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) {
|
if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) {
|
||||||
require __DIR__ . '/api/tickets.php';
|
require __DIR__ . '/api/tickets.php';
|
||||||
exit;
|
exit;
|
||||||
@@ -234,32 +239,43 @@ if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$uid = (int) ($user['id'] ?? 0);
|
$uid = (int) ($user['id'] ?? 0);
|
||||||
$action = (string) ($_POST['action'] ?? '');
|
$action = (string) ($_POST['action'] ?? '');
|
||||||
if ($action === 'start_totp') {
|
if ($action === 'start_totp') {
|
||||||
$secret = AuthTotp::generateSecret();
|
if (AuthFactors::hasTotp($uid)) {
|
||||||
$_SESSION['totp_enroll_secret'] = $secret;
|
$securityError = 'Remove the current authenticator before enrolling a new one.';
|
||||||
$_SESSION['totp_enroll_exp'] = time() + 900;
|
|
||||||
} elseif ($action === 'confirm_totp') {
|
|
||||||
$secret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
|
||||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
|
||||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
|
||||||
$code = trim($_POST['code'] ?? '');
|
|
||||||
if ($secret === '' || $exp < time() || !AuthTotp::verify($secret, $code)) {
|
|
||||||
$securityError = 'Invalid code — try setup again.';
|
|
||||||
} else {
|
} else {
|
||||||
AuthFactors::enrollTotp($uid, $secret);
|
$secret = AuthTotp::generateSecret();
|
||||||
$securitySuccess = 'Authenticator enrolled.';
|
$_SESSION['totp_enroll_secret'] = $secret;
|
||||||
|
$_SESSION['totp_enroll_exp'] = time() + 900;
|
||||||
|
}
|
||||||
|
} elseif ($action === 'confirm_totp') {
|
||||||
|
if (AuthFactors::hasTotp($uid)) {
|
||||||
|
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||||
|
$securityError = 'Authenticator is already enrolled.';
|
||||||
|
} else {
|
||||||
|
$secret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
||||||
|
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||||
|
$code = trim($_POST['code'] ?? '');
|
||||||
|
if ($secret === '' || $exp < time()) {
|
||||||
|
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||||
|
$securityError = 'Setup expired — start again.';
|
||||||
|
} elseif (!AuthTotp::verify($secret, $code, 2)) {
|
||||||
|
$securityError = 'Invalid code — check device time and try again.';
|
||||||
|
} else {
|
||||||
|
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||||
|
AuthFactors::enrollTotp($uid, $secret);
|
||||||
|
$securitySuccess = 'Authenticator enrolled.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} elseif ($action === 'remove_totp') {
|
} elseif ($action === 'remove_totp') {
|
||||||
AuthFactors::removeTotp($uid);
|
AuthFactors::removeTotp($uid);
|
||||||
$securitySuccess = 'Authenticator removed.';
|
$securitySuccess = 'Authenticator removed.';
|
||||||
}
|
}
|
||||||
$totpSecret = '';
|
if (!empty($securityError)) {
|
||||||
if (!AuthFactors::hasTotp($uid)) {
|
$_SESSION['security_flash_error'] = $securityError;
|
||||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
|
||||||
if ($exp >= time()) {
|
|
||||||
$totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
require __DIR__ . '/../views/account_security.php';
|
if (!empty($securitySuccess)) {
|
||||||
|
$_SESSION['security_flash_success'] = $securitySuccess;
|
||||||
|
}
|
||||||
|
header('Location: ' . Auth::basePath() . '/account-security', true, 303);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +283,9 @@ if ($route === '/account-security') {
|
|||||||
Auth::check();
|
Auth::check();
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$uid = (int) ($user['id'] ?? 0);
|
$uid = (int) ($user['id'] ?? 0);
|
||||||
|
$securityError = (string) ($_SESSION['security_flash_error'] ?? '');
|
||||||
|
$securitySuccess = (string) ($_SESSION['security_flash_success'] ?? '');
|
||||||
|
unset($_SESSION['security_flash_error'], $_SESSION['security_flash_success']);
|
||||||
$totpSecret = '';
|
$totpSecret = '';
|
||||||
if (!AuthFactors::hasTotp($uid)) {
|
if (!AuthFactors::hasTotp($uid)) {
|
||||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ final class AuthTotp {
|
|||||||
. '&data=' . rawurlencode($otpauthUri);
|
. '&data=' . rawurlencode($otpauthUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function verify(string $secret, string $code, int $window = 1): bool {
|
public static function verify(string $secret, string $code, int $window = 2): bool {
|
||||||
$code = preg_replace('/\s+/', '', $code) ?? '';
|
$code = preg_replace('/\s+/', '', $code) ?? '';
|
||||||
if (!preg_match('/^\d{6}$/', $code)) {
|
if (!preg_match('/^\d{6}$/', $code)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
<div class="toolbar reports-toolbar">
|
<div class="toolbar reports-toolbar">
|
||||||
<h1 data-i18n="tickets.title">Tickets</h1>
|
<h1 data-i18n="tickets.title">Tickets</h1>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="tickets-new-btn" data-i18n="tickets.new">New ticket</button>
|
||||||
<label class="toolbar-select">
|
<label class="toolbar-select">
|
||||||
<span data-i18n="theme.label">Theme</span>
|
<span data-i18n="theme.label">Theme</span>
|
||||||
<select id="theme-select" aria-label="UI theme">
|
<select id="theme-select" aria-label="UI theme">
|
||||||
@@ -222,6 +223,22 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<nav class="reports-pagination" id="tickets-pagination" aria-label="Tickets pages"></nav>
|
<nav class="reports-pagination" id="tickets-pagination" aria-label="Tickets pages"></nav>
|
||||||
|
<dialog id="ticket-create-dialog" class="modal-dialog">
|
||||||
|
<form method="dialog" id="ticket-create-form" class="modal-form">
|
||||||
|
<h2 data-i18n="tickets.new">New ticket</h2>
|
||||||
|
<label><span data-i18n="ticket.title">Title</span>
|
||||||
|
<input name="title" id="ticket-create-title" required maxlength="256"></label>
|
||||||
|
<label><span data-i18n="ticket.brief">Brief</span>
|
||||||
|
<input name="brief" id="ticket-create-brief" maxlength="512"></label>
|
||||||
|
<label><span data-i18n="ticket.body">Description</span>
|
||||||
|
<textarea name="body" id="ticket-create-body" rows="8" required></textarea></label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn" id="ticket-create-cancel" data-i18n="ticket.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" data-i18n="ticket.create">Create</button>
|
||||||
|
</div>
|
||||||
|
<p id="ticket-create-status" class="muted" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
<?php elseif (($view ?? '') === 'reports'): ?>
|
<?php elseif (($view ?? '') === 'reports'): ?>
|
||||||
<div id="reports-app" class="reports-app" data-grouped="0">
|
<div id="reports-app" class="reports-app" data-grouped="0">
|
||||||
|
|||||||
Reference in New Issue
Block a user