feat: security hardening, streaming overhaul, design polish, tests

Security:
- Replace unsalted SHA-256 password hashing with bcrypt (lazy rehash on login)
- Add authenticated /api/xtream-api gateway: Xtream credentials are injected
  server-side and never sent to the frontend; /api/playlists no longer
  returns passwords
- Redact credentials from all logs (login body, proxy/FFmpeg/scheduler URLs)
- Add auth to recordings, EPG, season-passes and streaming routes
  (HttpOnly session cookie for hls.js; loopback bypass for local FFmpeg)
- Lock player postMessage to same-origin in both directions
- Vendor and pin hls.js 1.6.7 / mpegts.js 1.7.3 (drop CDN @latest)
- Fix rate limiter (client IP was never resolved), add login rate limit,
  restrict CORS, add CSP Report-Only, block private-IP SSRF targets,
  fix path traversal in recording log retrieval, chmod 777 -> 770
- Remove dead HiveService (seeded admin/admin into IndexedDB with SHA-256)
- Fix authMiddleware not populating 'user' context (getPlaylist ignored the
  logged-in user; admin purge always returned 403)

Streaming:
- New FfmpegSessionManager: process registry, idle reaper (4 min live /
  15 min VOD), orphan cleanup at startup, clean SIGTERM shutdown,
  fast-fail with stderr instead of 30 s timeout
- Quality selection (source/high/medium/low) for live and VOD; source mode
  streams with -c:v copy (zero transcoding); selector wired into the player
- Concurrent recordings (MAX_CONCURRENT_RECORDINGS, default 2); conflicts
  retry on the next tick instead of silently failing
- Lower live latency (HLS window 20 -> 10 segments, liveSync 10 -> 3)
- Fix recording log lookup (.mp4 vs .mkv mismatch)

Design:
- Replace hardcoded colors with AppColors tokens (12 files)
- web/theme.css syncs HTML players with the Flutter palette
- DPAD/keyboard navigation (arrow-key focus, player shortcuts)
- Tooltips on player icon buttons, Semantics on content cards
- Remove 7 dead widgets broken since the Stitch merge

Quality:
- bin/test/: 21 unit tests (bcrypt, redaction, traversal, SSRF, recording
  conflicts) plus a quality-selector widget test
- GitHub Actions CI (analyze + test + build web)
- Archive stale status docs into docs/archive/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-06-10 10:07:18 +02:00
1 parent cb5eca7547
commit 60d3f42901
107 files changed
+4525 -3815

No files matched your search

+22 -17
View File
@@ -4,9 +4,10 @@
<head>
<meta charset="UTF-8">
<title>XtremFlow Player</title>
<!-- HLS.js and mpegts.js for MPEG-TS support -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.min.js"></script>
<!-- Vendored, pinned player libraries (no CDN dependency) -->
<script src="vendor/hls.min.js"></script>
<script src="vendor/mpegts.min.js"></script>
<link rel="stylesheet" href="theme.css">
<style>
* {
margin: 0;
@@ -17,7 +18,7 @@
html,
body {
background: #000;
background: var(--color-bg);
width: 100%;
height: 100%;
overflow: hidden;
@@ -36,7 +37,7 @@
#video {
width: 100%;
height: 100%;
background: #000;
background: var(--color-bg);
}
#error {
@@ -45,7 +46,7 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
color: var(--color-text);
text-align: center;
font-family: Arial, sans-serif;
z-index: 30;
@@ -53,7 +54,7 @@
#error-icon {
font-size: 64px;
color: #f44336;
color: var(--color-error);
margin-bottom: 16px;
}
@@ -63,7 +64,7 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
color: var(--color-text);
text-align: center;
font-family: Arial, sans-serif;
z-index: 20;
@@ -72,8 +73,8 @@
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border: 4px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
margin: 0 auto 16px;
@@ -93,13 +94,13 @@
transform: translate(-50%, -50%);
width: 90px;
height: 90px;
background: rgba(0, 0, 0, 0.6);
background: var(--color-overlay);
border-radius: 50%;
display: none;
justify-content: center;
align-items: center;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
border: 2px solid var(--color-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
z-index: 25;
@@ -183,6 +184,9 @@
}
});
// Parent (Flutter app) is always same-origin: never broadcast to '*'
const PARENT_ORIGIN = window.location.origin;
let lastReportedTime = 0;
function reportPosition() {
if (video.currentTime > 0 && !video.paused && video.readyState > 2) {
@@ -197,32 +201,33 @@
} else if (injectedDuration > 0 && duration < injectedDuration * 0.9) {
duration = injectedDuration;
}
window.parent.postMessage({ type: 'playback_position', currentTime: video.currentTime, duration: duration }, '*');
window.parent.postMessage({ type: 'playback_position', currentTime: video.currentTime, duration: duration }, PARENT_ORIGIN);
}
}
}
setInterval(reportPosition, 5000);
video.addEventListener('pause', () => {
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, PARENT_ORIGIN);
});
video.addEventListener('playing', () => {
window.parent.postMessage({ type: 'playback_status', status: 'playing' }, '*');
window.parent.postMessage({ type: 'playback_status', status: 'playing' }, PARENT_ORIGIN);
hideLoading();
playOverlay.style.display = 'none';
});
video.addEventListener('ended', () => {
window.parent.postMessage({ type: 'playback_ended', duration: isFinite(video.duration) ? video.duration : injectedDuration }, '*');
window.parent.postMessage({ type: 'playback_ended', duration: isFinite(video.duration) ? video.duration : injectedDuration }, PARENT_ORIGIN);
});
function reportActivity() { window.parent.postMessage({ type: 'user_activity' }, '*'); }
function reportActivity() { window.parent.postMessage({ type: 'user_activity' }, PARENT_ORIGIN); }
document.addEventListener('mousemove', reportActivity);
document.addEventListener('touchstart', reportActivity);
document.addEventListener('click', reportActivity);
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
if (!data) return;
switch (data.type) {