From c6262aceab1049b73b485eea385c4a6cbda20ca1 Mon Sep 17 00:00:00 2001 From: larssand Date: Fri, 28 Aug 2026 21:12:43 +0200 Subject: [PATCH] fix live leaderbord details --- README.md | 3 + README.sv.md | 3 + docs/javascript-architecture.md | 1 + docs/security-performance-audit.md | 128 +++++++++++++++++++ package.json | 1 + server.js | 28 +++- src/app.js | 46 ++++++- src/decoder_runtime.js | 22 ++-- src/misc_views.js | 2 +- src/overlays.js | 12 +- src/runtime_services.js | 16 ++- src/timing_views.js | 11 +- tests/decoder_runtime.test.js | 12 +- tests/runtime_persistence.test.js | 27 ++++ tests/server_persistence.integration.test.js | 15 +++ 15 files changed, 293 insertions(+), 34 deletions(-) create mode 100644 docs/security-performance-audit.md diff --git a/README.md b/README.md index 6aeaf25..d6039e2 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ RC RaceCore is an RC timing and race-control system with support for sponsor eve ## Architecture - JavaScript module overview: `docs/javascript-architecture.md` +- Security and performance audit: `docs/security-performance-audit.md` - Swedish operational guide: `README.sv.md` ## Screenshots @@ -51,6 +52,8 @@ If you want simple protection for public overlays, set this environment variable PUBLIC_OVERLAY_TOKEN=your-secret-token ``` +This token gates the overlay route only. Do not expose port `8081` directly to the internet because administration APIs are not authenticated. Use a trusted race LAN, VPN, or authenticated reverse proxy. If the UI is intentionally served from another origin, list it in `RC_ALLOWED_ORIGINS` (comma-separated). See [`docs/security-performance-audit.md`](docs/security-performance-audit.md). + Then the public overlay URL must include: ```text diff --git a/README.sv.md b/README.sv.md index 281bdaf..4fdfe50 100644 --- a/README.sv.md +++ b/README.sv.md @@ -5,6 +5,7 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he ## Arkitektur - JavaScript-modulöversikt: `docs/javascript-architecture.md` +- Säkerhets- och prestandaaudit: `docs/security-performance-audit.md` - Primär engelsk README: `README.md` ## Skärmbilder @@ -49,6 +50,8 @@ Om du vill ha ett enkelt skydd för publika overlays, sätt denna miljövariabel PUBLIC_OVERLAY_TOKEN=din-hemliga-token ``` +Tokenen skyddar bara overlay-routen. Exponera inte port `8081` direkt mot internet eftersom administrations-API:erna saknar autentisering. Använd betrott race-LAN, VPN eller en autentiserad reverse proxy. Om webbgränssnittet avsiktligt körs från en annan origin anges den i `RC_ALLOWED_ORIGINS` (kommaseparerat). Se [`docs/security-performance-audit.md`](docs/security-performance-audit.md). + Då måste den publika overlay-länken innehålla: ```text diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 65c8261..eb42b37 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -157,6 +157,7 @@ Runtime environment variables: - `RC_TIMING_DATA_DIR`: optional absolute/relative data directory override; defaults to `/data` - `RC_TIMING_PID_FILE`: optional PID-file path used by process control - `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes +- `RC_ALLOWED_ORIGINS`: optional comma-separated cross-origin UI origins; same-origin is always allowed ### `server/public_training_profile.js` Role: diff --git a/docs/security-performance-audit.md b/docs/security-performance-audit.md new file mode 100644 index 0000000..3756976 --- /dev/null +++ b/docs/security-performance-audit.md @@ -0,0 +1,128 @@ +# Security and Performance Audit + +Audit date: 2026-08-28 + +## Scope + +This review covers the Node/Express backend, SQLite persistence, browser runtime, decoder WebSocket processing, overlay refresh path, dependency tree, and bundled runtime assets. It is a source-level review plus the repository test and npm vulnerability scans. It is not a penetration test of a deployed network. + +## Verification baseline + +- `npm audit --omit=dev`: 0 known vulnerabilities across 105 production dependencies +- `npm test`: 74 tests passing after the changes in this audit +- `npm run verify`: Express and SQLite runtime verified +- Runtime target: Node.js 22-26 + +## Changes applied during this audit + +### Overlay rendering + +The overlay previously rebuilt its complete HTML tree from three independent loops: backend hydration every 800 ms, live refresh every 250 ms, and the one-second session timer. This caused repeated leaderboard calculation, HTML generation, DOM replacement, image handling, and event-listener registration. + +The live 250 ms path now updates only: + +- elapsed/remaining clock nodes +- predicted-lap text +- predicted-lap progress width and status color + +Full overlay rendering remains event-driven for passings and state changes, and follows backend hydration for remote overlay clients. + +### Backend synchronization + +The backend state poll now runs only while the current view is `Overlay`. It no longer hydrates the full application state every 800 ms while an operator edits Settings, Race Setup, Drivers, Cars, or other administration views. + +### Decoder messages + +Only recognized decoder passings that mutate race state now trigger state persistence and a view render. Status, keepalive, malformed, ignored, and duplicate channel messages no longer serialize the complete state or rebuild the active page. + +### Cross-origin requests + +The backend no longer returns wildcard CORS headers. Requests with an `Origin` header are accepted from the server's own origin or from origins explicitly listed in `RC_ALLOWED_ORIGINS`. Other origins receive `403`, including simple cross-origin POST requests that do not use a browser preflight. + +Responses also include: + +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: no-referrer` + +## Security findings + +### Critical: administration API has no authentication + +All users that can reach port 8081 can currently read or replace application state and operate managed AMMC. The most sensitive endpoints are: + +- `GET/POST /api/state` +- `GET/POST /api/ammc/config` +- `POST /api/ammc/start` +- `POST /api/ammc/stop` +- `POST /api/passings` +- `POST /api/export/pdf` + +AMMC configuration includes an executable path and arguments. Because the backend can start that executable, access to these endpoints must be treated as administrative host access. + +Required deployment rule: do not expose port 8081 directly to the public internet. Keep it on a trusted race LAN, behind a VPN, or behind an authenticated reverse proxy. + +Recommended code change: add authenticated operator sessions or an admin API token, protect every mutating/admin endpoint, and keep public timing endpoints separate. + +### High: public overlay token does not protect the state API + +`PUBLIC_OVERLAY_TOKEN` gates the public overlay HTML route, but the overlay still hydrates through the general `/api/state` endpoint. That endpoint contains full operational state and is not token-protected or sanitized. + +Therefore `PUBLIC_OVERLAY_TOKEN` is only a route gate, not complete data/API security. Before publishing an overlay outside the race LAN, add a read-only, sanitized public-overlay state endpoint and make public overlays use only that endpoint. + +### Medium: monolithic state import increases stored-XSS impact + +Most dynamic UI values are escaped before insertion into HTML. However, imported JSON and unrestricted `/api/state` replacement can place attacker-controlled identifiers and configuration into many UI paths. Authentication and strict import schema validation should be the primary controls. A Content Security Policy should be added after inline styles have been removed or explicitly accounted for. + +### Medium: large request bodies and no rate limiting + +The global JSON limit is 20 MB and administrative endpoints have no request-rate control. This is acceptable only on a trusted LAN. Add per-route limits and rate limiting before any internet-facing deployment. + +### Medium: bundled AMMC binaries are trusted artifacts + +The repository includes large platform binaries and an archive. Installation currently verifies presence, not cryptographic hashes or signatures. Record vendor versions and expected SHA-256 hashes, then verify them during installation or release packaging. + +### Low: health endpoint exposes the database path + +`/api/health` returns the absolute SQLite path. This is useful for local diagnostics but unnecessary on a public deployment. Remove it or expose detailed health data only to authenticated operators. + +## Performance findings + +### High: monolithic state serialization and SQLite rewrite + +`saveState()` serializes the complete application state into localStorage. Backend synchronization stores the same state as one large JSON document. `/api/passings` also parses and rewrites the state document to keep remote overlays current. + +Cost grows with all historical sessions, results, passings, training records, and audio events. This is the main long-term scaling limit. + +Recommended design: + +1. Keep configuration and small UI state in `app_state`. +2. Store sessions, competitors, results, passings, training laps, and audit events in normalized SQLite tables. +3. Update only the active session result on a passing. +4. Publish active-session deltas through WebSocket or Server-Sent Events instead of polling full state. + +### Medium: leaderboard and track-record calculations repeat on each passing + +A valid passing builds the leaderboard before and after mutation, scans prior class results for the track record, and can build it again during rendering. This is acceptable for roughly 10-12 cars but will become visible with larger endurance histories. + +Recommended design: cache active-session leaderboard inputs and per-class track records; invalidate only the affected competitor and class. + +### Medium: full view replacement on each valid passing + +Timing and overlay views still replace substantial HTML after each valid passing. The 250 ms overlay animation path is now incremental, but passing-driven rendering remains full-page. Move leaderboard rows to keyed incremental updates if profiling shows frame drops during larger races. + +### Low: source delivery is unbundled and unminified + +ES modules are appropriate for the current local deployment. The size of `app.js` affects maintainability more than runtime by itself, but production releases would benefit from bundling, minification, hashed assets, and long-lived cache headers. Development currently uses `no-store` intentionally to support live file reload. + +### Distribution size + +The AMMC archive and multi-platform binaries dominate repository and deployment size. Build platform-specific release archives instead of syncing all AMMC targets to each timing computer. + +## Recommended implementation order + +1. Add admin authentication and a sanitized public overlay API. +2. Replace full-state overlay polling with server-pushed active-session deltas. +3. Normalize active race results and training history into SQLite tables. +4. Add request limits, rate limiting, import schema validation, and CSP. +5. Add AMMC checksum verification and platform-specific release packaging. +6. Profile a simulated 12-car race and an endurance dataset before deeper leaderboard optimization. diff --git a/package.json b/package.json index 213c583..e78ffbf 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "status": "node scripts/serverctl.js status", "restart": "node scripts/serverctl.js restart", "verify": "node scripts/verify-install.js", + "audit:prod": "npm audit --omit=dev", "test": "node --experimental-default-type=module tests/run.js", "setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1" }, diff --git a/server.js b/server.js index 919ad59..e63c054 100644 --- a/server.js +++ b/server.js @@ -59,13 +59,33 @@ const ammcState = { }; const app = express(); +const allowedCorsOrigins = new Set( + String(process.env.RC_ALLOWED_ORIGINS || "") + .split(",") + .map((origin) => origin.trim().replace(/\/$/, "")) + .filter(Boolean) +); + app.use(express.json({ limit: "20mb" })); app.use((req, res, next) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "no-referrer"); + + const requestOrigin = `${req.protocol}://${req.get("host")}`; + const origin = String(req.get("origin") || "").replace(/\/$/, ""); + const corsAllowed = !origin || origin === requestOrigin || allowedCorsOrigins.has(origin); + if (!corsAllowed) { + res.status(403).end(); + return; + } + if (origin) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + } if (req.method === "OPTIONS") { - res.status(204).end(); + res.status(corsAllowed ? 204 : 403).end(); return; } next(); diff --git a/src/app.js b/src/app.js index 81cade6..bf7293f 100644 --- a/src/app.js +++ b/src/app.js @@ -140,7 +140,7 @@ const checkAppVersion = () => checkAppVersionHelper({ getBackendUrl, getBaseline const startAppVersionPolling = () => startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer: () => appVersionPollTimer, setAppVersionPollTimer: (value) => { appVersionPollTimer = value; } }); const startOverlaySync = () => startOverlaySyncHelper({ getOverlaySyncTimer: () => overlaySyncTimer, setOverlaySyncTimer: (value) => { overlaySyncTimer = value; }, getLocalStateDirty: () => localStateDirty, hydrateFromBackend, getCurrentView: () => currentView, renderView }); const startOverlayRotation = () => startOverlayRotationHelper({ getOverlayRotationTimer: () => overlayRotationTimer, setOverlayRotationTimer: (value) => { overlayRotationTimer = value; }, getOverlayRotationIndex: () => overlayRotationIndex, setOverlayRotationIndex: (value) => { overlayRotationIndex = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderView }); -const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderOverlay }); +const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, refreshOverlayDynamics }); const ensureAudioContext = () => ensureAudioContextHelper({ state, getAudioCtx: () => audioCtx, setAudioCtx: (value) => { audioCtx = value; } }); const playPassingBeep = () => playPassingBeepHelper({ ensureAudioContext }); const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext }); @@ -180,7 +180,7 @@ const startSessionWithAudio = (session, callbacks) => { const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || "")); return raceAudioEngine.runStartSequence(session, { ...callbacks, skipAudio: practice }); }; -const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, updateHeaderState }); +const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, refreshTimingDynamics, updateHeaderState }); const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog, renderSessionChannelAnalysis }); const connectDecoder = () => connectDecoderHelper({ state, @@ -239,6 +239,48 @@ const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar }); const renderGuide = () => renderGuideView({ dom, t }); const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl }); +const refreshOverlayDynamics = () => { + const active = getActiveSession(); + if (!active) return; + + const timing = getSessionTiming(active); + const clock = timing.followUpActive + ? formatCountdown(timing.followUpRemainingMs) + : timing.untimed + ? formatElapsedClock(timing.elapsedMs) + : formatCountdown(timing.remainingMs); + document.querySelectorAll("[data-overlay-clock]").forEach((node) => { + node.textContent = clock; + }); + + const rowsByKey = new Map(buildLeaderboard(active).map((row) => [String(row.key), row])); + document.querySelectorAll("[data-overlay-competitor]").forEach((node) => { + const row = rowsByKey.get(String(node.dataset.overlayCompetitor || "")); + if (!row) return; + const prediction = node.querySelector("[data-overlay-prediction]"); + const fill = node.querySelector("[data-overlay-prediction-fill]"); + if (prediction) prediction.textContent = formatPredictedLapDelta(row.predictedRemainingMs); + if (fill instanceof HTMLElement) { + fill.style.width = `${Math.max(0, Math.min(100, Math.round(Math.min(1, row.predictedProgress || 0) * 100)))}%`; + fill.classList.remove("overlay-prediction-good", "overlay-prediction-warn", "overlay-prediction-overdue"); + fill.classList.add(`overlay-prediction-${row.predictionTone || "good"}`); + } + }); +}; +const refreshTimingDynamics = () => { + const active = getActiveSession(); + if (!active) return; + + const timing = getSessionTiming(active); + const clock = timing.followUpActive + ? formatCountdown(timing.followUpRemainingMs) + : timing.untimed + ? formatElapsedClock(timing.elapsedMs) + : formatCountdown(timing.remainingMs); + document.querySelectorAll("[data-timing-clock]").forEach((node) => { + node.textContent = clock; + }); +}; const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, startSessionWithAudio, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index 3c6bfdd..fdfa381 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -177,15 +177,11 @@ function connectDecoderRole(role, deps) { return; } - if (Array.isArray(parsed)) { - parsed.forEach((message) => processDecoderMessage(message, role)); - } else { - processDecoderMessage(parsed, role); - } + const messages = Array.isArray(parsed) ? parsed : [parsed]; + const changed = messages.reduce((didChange, message) => processDecoderMessage(message, role) || didChange, false); - saveState(); updateConnectionBadge(); - if (["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) { + if (changed && ["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) { renderView(); } }; @@ -330,7 +326,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`); triggerRaceAudio("entering_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } }); saveState(); - return; + return true; } if (decoderRole === "pit_exit") { @@ -360,7 +356,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`); triggerRaceAudio("leaving_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } }); saveState(); - return; + return true; } if (decoderRole === "split") { @@ -392,7 +388,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { }); pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`); saveState(); - return; + return true; } if (startMode === "staggered" && !entry.startTimestamp) { @@ -400,7 +396,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { entry.lastTimestamp = timestamp; announcePassing(entry); saveState(); - return; + return true; } if (!entry.startTimestamp) { @@ -448,7 +444,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { } persistPassingToBackend(session.id, passing); saveState(); - return; + return true; } const previousPersonalBest = Number(entry.bestLapMs || 0) || null; @@ -500,4 +496,6 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { } } announcePassing(entry); + saveState(); + return true; } diff --git a/src/misc_views.js b/src/misc_views.js index fed239c..8df2f68 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -160,7 +160,7 @@ export function renderOverlayPageView(deps) {
- ${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `
${overlayClock}
`} + ${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `
${overlayClock}
`}
${escapeHtml(overlayStatusLabel)}
`} diff --git a/src/overlays.js b/src/overlays.js index da63e9c..cc04909 100644 --- a/src/overlays.js +++ b/src/overlays.js @@ -65,7 +65,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive .map((row, idx) => { const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; return ` -
+
${idx + 1}
@@ -77,10 +77,10 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
- ${formatPredictedLapDelta(row.predictedRemainingMs)} + ${formatPredictedLapDelta(row.predictedRemainingMs)}
-
+
@@ -223,7 +223,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra ${obsConfig.showClock ? `
- ${elapsedOrRemaining} + ${elapsedOrRemaining} ${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}
` : ""} @@ -247,7 +247,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra

${escapeHtml(getEventName(active.eventId))}

${escapeHtml(active.name)}

- ${obsConfig.showClock ? `${elapsedOrRemaining}` : ""} + ${obsConfig.showClock ? `${elapsedOrRemaining}` : ""}
${compactRows.slice(0, 6).map((row, idx) => { @@ -317,7 +317,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra

${escapeHtml(active.name)}

- ${obsConfig.showClock ? `
${elapsedOrRemaining}
` : ""} + ${obsConfig.showClock ? `
${elapsedOrRemaining}
` : ""}
${escapeHtml(getSessionTypeLabel(active.type))} diff --git a/src/runtime_services.js b/src/runtime_services.js index d8818f8..cfab9ab 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -235,8 +235,9 @@ export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPol export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) { clearInterval(getOverlaySyncTimer()); setOverlaySyncTimer(setInterval(async () => { + if (getCurrentView() !== "overlay") return; if (!getLocalStateDirty()) await hydrateFromBackend(); - if (getCurrentView() === "overlay") renderView(); + renderView(); }, 800)); } @@ -248,10 +249,10 @@ export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlay }, 8000)); } -export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, renderOverlay }) { +export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, refreshOverlayDynamics }) { clearInterval(getOverlayLiveRefreshTimer()); setOverlayLiveRefreshTimer(setInterval(() => { - if (getCurrentView() === "overlay" && ["leaderboard", "tv", "team"].includes(getOverlayViewMode())) renderOverlay(); + if (getCurrentView() === "overlay" && ["leaderboard", "speaker", "results", "tv", "team", "obs"].includes(getOverlayViewMode())) refreshOverlayDynamics(); }, 250)); } @@ -418,12 +419,15 @@ export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSessio return { changed: true }; } -export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, updateHeaderState }) { +export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, refreshTimingDynamics = () => {}, updateHeaderState }) { dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US"); const timerState = handleSessionTimerTick(); const active = getActiveSession(); - if (getCurrentView() === "timing" && active && (active.status === "running" || timerState.changed)) renderView(); + if (getCurrentView() === "timing" && active) { + if (timerState.changed) renderView(); + else if (active.status === "running") refreshTimingDynamics(); + } if (getCurrentView() === "dashboard" && timerState.changed) renderView(); - if (getOverlayMode() && getCurrentView() === "overlay" && active) renderView(); + if (getOverlayMode() && getCurrentView() === "overlay" && active && timerState.changed) renderView(); if (timerState.changed) updateHeaderState(); } diff --git a/src/timing_views.js b/src/timing_views.js index 6ca80bf..0f88b64 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -257,6 +257,7 @@ function renderRecentPassings(session, deps) { } export function renderTimingView(deps) { + const leaderboardModalScrollTop = document.querySelector("#leaderboardModalOverlay .modal-card")?.scrollTop || 0; const { state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, @@ -369,7 +370,7 @@ export function renderTimingView(deps) { ${escapeHtml(getStatusLabel(active.status))}
-
${clockLabel}${clockValue}
+
${clockLabel}${clockValue}
${t("timing.started")}${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}
${t("table.start_mode")}${escapeHtml(getStartModeLabel(active.startMode))}
${t("timing.seeding_mode")}${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}
@@ -440,6 +441,11 @@ export function renderTimingView(deps) { ${selectedRow && active ? renderLeaderboardModal(active, selectedRow, { ...helperDeps, renderQuickAddActions: (session, transponder, idPrefix) => renderQuickAddActions(session, transponder, idPrefix, helperDeps) }) : ""} `; + if (selectedRow && leaderboardModalScrollTop > 0) { + const modalCard = document.querySelector("#leaderboardModalOverlay .modal-card"); + if (modalCard instanceof HTMLElement) modalCard.scrollTop = leaderboardModalScrollTop; + } + document.getElementById("timingConnect")?.addEventListener("click", () => { const input = document.getElementById("timingWsUrl"); if (input && input.value.trim()) { @@ -573,7 +579,7 @@ export function renderTimingView(deps) { if (!tp) { return; } - processDecoderMessage({ + const changed = processDecoderMessage({ msg: "PASSING", transponder: tp, rtc_time: new Date().toISOString(), @@ -581,6 +587,7 @@ export function renderTimingView(deps) { resend: false, loop_id: "sim", }); + if (changed) renderView(); }); document.getElementById("setActiveSession")?.addEventListener("click", () => { diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index 9f614c2..db4fbfb 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -59,7 +59,7 @@ function createHarness() { test("split decoder records intermediate time without changing main lap state", () => { const harness = createHarness(); - harness.passing(17_000, "main"); + assert.equal(harness.passing(17_000, "main"), true); const result = harness.state.resultsBySession.session_1; const entry = result.competitors.team_team_1; assert.equal(entry.laps, 1); @@ -150,3 +150,13 @@ test("connect decoder opens one websocket per enabled role", () => { globalThis.WebSocket = OriginalWebSocket; } }); + +test("non-passing decoder messages do not persist race state", () => { + const harness = createHarness(); + const changed = processDecoderMessageHelper({ msg: "STATUS", decoder: "online" }, { + state: harness.state, + getActiveSession: () => harness.session, + }); + assert.equal(Boolean(changed), false); + assert.equal(harness.getSaveCount(), 0); +}); diff --git a/tests/runtime_persistence.test.js b/tests/runtime_persistence.test.js index 8d72cd1..a7f15d8 100644 --- a/tests/runtime_persistence.test.js +++ b/tests/runtime_persistence.test.js @@ -5,6 +5,7 @@ import { applyPersistedStateHelper, hydrateFromBackendHelper, syncStateToBackendHelper, + tickClockHelper, } from "../src/runtime_services.js"; function withMockFetch(mock, callback) { @@ -284,3 +285,29 @@ test("failed state sync leaves version and dirty state unchanged", async () => { assert.equal(backend.available, false); assert.equal(backend.lastError, "error.sync_failed:connection refused"); }); + +test("running timing clock updates without rebuilding the leaderboard details modal", () => { + let renders = 0; + let dynamicRefreshes = 0; + let headerRefreshes = 0; + const base = { + dom: { clock: { textContent: "" } }, + currentLanguage: () => "sv", + getActiveSession: () => ({ status: "running" }), + getCurrentView: () => "timing", + getOverlayMode: () => false, + renderView: () => { renders += 1; }, + refreshTimingDynamics: () => { dynamicRefreshes += 1; }, + updateHeaderState: () => { headerRefreshes += 1; }, + }; + + tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: false }) }); + assert.equal(dynamicRefreshes, 1); + assert.equal(renders, 0); + assert.equal(headerRefreshes, 0); + + tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: true }) }); + assert.equal(dynamicRefreshes, 1); + assert.equal(renders, 1); + assert.equal(headerRefreshes, 1); +}); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index 71efb75..e450ca9 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -74,6 +74,7 @@ test("SQLite API persists state, passings, and overlay session results", { timeo PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "", + RC_ALLOWED_ORIGINS: "https://trusted.example", }, stdio: ["ignore", "pipe", "pipe"], }); @@ -87,6 +88,20 @@ test("SQLite API persists state, passings, and overlay session results", { timeo assert.equal(health.ok, true); assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite")); + const sameOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: baseUrl } }); + assert.equal(sameOriginResponse.status, 200); + assert.equal(sameOriginResponse.headers.get("access-control-allow-origin"), baseUrl); + + const allowedOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: "https://trusted.example" } }); + assert.equal(allowedOriginResponse.status, 200); + assert.equal(allowedOriginResponse.headers.get("access-control-allow-origin"), "https://trusted.example"); + + const rejectedOriginResponse = await fetch(`${baseUrl}/api/ammc/start`, { + method: "POST", + headers: { Origin: "https://untrusted.example" }, + }); + assert.equal(rejectedOriginResponse.status, 403); + const soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`); const soundManifest = await soundManifestResponse.json(); assert.equal(soundManifestResponse.status, 200);