Files
xtremflow/server/src/api/player_log.rs
T
MichaelandClaude Opus 5.5 3ea6fa0ae3 Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

La base de production est reprise telle quelle : schéma, migrations et
formats de dates (DateTime.toIso8601String de Dart) sont reproduits à
l'identique, les sessions et hachages existants restent valides.

Comportement conservé : cascade de routage, anti-SSRF, logs masqués,
rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur,
enregistrements (reprise, relances, fusion), season passes, EPG, logos de
repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ;
la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le
serveur dans un étage rust:1.98.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:57 +02:00

136 lines
4.2 KiB
Rust

//! Journal des incidents de lecture remontés par le lecteur web.
//!
//! POURQUOI : les freezes se produisent chez l'utilisateur, dans son
//! navigateur ; le serveur n'en voit rien. Le lecteur envoie ici chaque
//! coupure, erreur, recréation et saut, et le serveur les écrit dans
//! `docker logs` (`[Player] …`).
//!
//! Entrée non fiable : seuls des champs connus, courts et typés passent.
use crate::http::{self, Req, Resp};
use regex::Regex;
use serde_json::Value;
use std::sync::LazyLock;
const EVENTS: [&str; 11] = [
"start",
"first_frame",
"stall",
"seek",
"mpegts_error",
"recreate",
"fallback_hls",
"hls_error",
"profile",
"error_shown",
"heartbeat",
];
const NUMERIC_FIELDS: [&str; 11] = [
"t", "ms", "buffer", "pos", "from", "to", "stalls", "stallMs", "seeks", "errors", "rate",
];
const TEXT_FIELDS: [&str; 5] = ["session", "ch", "mode", "profile", "detail"];
static URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://\S+").unwrap());
static UNSAFE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^A-Za-z0-9_.\-:/<>]").unwrap());
/// Ligne de journal pour l'événement [raw], ou `None` s'il est invalide.
pub fn format_player_event(raw: &Value) -> Option<String> {
let map = raw.as_object()?;
let event = map.get("event")?.as_str()?;
if !EVENTS.contains(&event) {
return None;
}
let mut parts = vec![event.to_string()];
for key in TEXT_FIELDS {
if let Some(value) = map.get(key).and_then(Value::as_str) {
if value.is_empty() {
continue;
}
// Ni espace, ni retour ligne, ni URL : jamais d'injection de faux
// journaux, jamais d'identifiants recopiés depuis une URL.
let safe = URL.replace_all(value, "<url>");
let safe = UNSAFE.replace_all(&safe, "_");
let safe: String = safe.chars().take(60).collect();
parts.push(format!("{key}={safe}"));
}
}
for key in NUMERIC_FIELDS {
let Some(Value::Number(n)) = map.get(key) else {
continue;
};
if let Some(i) = n.as_i64() {
parts.push(format!("{key}={i}"));
} else if let Some(f) = n.as_f64().filter(|f| f.is_finite()) {
parts.push(format!("{key}={f:.1}"));
}
}
Some(format!("[Player] {}", parts.join(" ")))
}
pub fn handle(req: &Req) -> Resp {
let body = req.body_str();
if body.encode_utf16().count() > 4096 {
return http::empty(413);
}
let Ok(decoded) = serde_json::from_str::<Value>(&body) else {
return http::text(400, "Bad Request");
};
let events: Vec<&Value> = match &decoded {
Value::Array(list) => list.iter().take(20).collect(),
other => vec![other],
};
for event in events {
if let Some(line) = format_player_event(event) {
println!("{line}");
}
}
http::empty(204)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ecrit_une_coupure_avec_sa_duree_et_le_buffer() {
assert_eq!(
format_player_event(&json!({
"event": "stall", "session": "ab12", "ch": "554034", "ms": 5230, "buffer": 0.04
}))
.unwrap(),
"[Player] stall session=ab12 ch=554034 ms=5230 buffer=0.0"
);
}
#[test]
fn refuse_un_evenement_inconnu() {
assert!(format_player_event(&json!({"event": "rm -rf"})).is_none());
assert!(format_player_event(&json!("stall")).is_none());
assert!(format_player_event(&Value::Null).is_none());
}
#[test]
fn pas_d_injection_ni_d_url() {
let line = format_player_event(&json!({
"event": "mpegts_error",
"detail": "NetworkError http://panel/live/user/pass/1.ts\n[Player] faux"
}))
.unwrap();
assert!(!line.contains('\n'));
assert!(!line.contains("user/pass"));
assert!(line.contains("<url>"));
}
#[test]
fn ignore_les_champs_inattendus() {
let line =
format_player_event(&json!({"event": "seek", "from": 10, "cookie": "secret"})).unwrap();
assert!(line.contains("from=10"));
assert!(!line.contains("to="));
assert!(!line.contains("secret"));
}
}