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>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-13 00:29:57 +02:00
1 parent f82c70ae05
commit 3ea6fa0ae3
109 files changed
+14008 -11047

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
//! Comptes : authentification, utilisateurs (admin), playlists, réglages.
use crate::http::{self, Req, Resp, extract_token, json, json_as_text, segments, text};
use crate::state::SharedState;
use crate::util::password;
use serde_json::{Value, json};
fn error_json(status: u16, error: impl Into<String>) -> Resp {
json(status, &json!({"success": false, "error": error.into()}))
}
fn str_field(payload: &Value, key: &str) -> Option<String> {
payload.get(key).and_then(Value::as_str).map(str::to_string)
}
/// Corps JSON objet, ou réponse 500 (comportement de l'ancien serveur sur
/// un corps illisible).
fn parse_object(req: &Req) -> Result<Value, Resp> {
match serde_json::from_slice::<Value>(&req.body) {
Ok(v) if v.is_object() => Ok(v),
Ok(_) => Err(error_json(
500,
"type is not a subtype of type 'Map<String, dynamic>'",
)),
Err(e) => Err(error_json(500, format!("FormatException: {e}"))),
}
}
/// Longueur à la manière de Dart (unités UTF-16).
fn dart_len(s: &str) -> usize {
s.encode_utf16().count()
}
// ==================== /api/auth ====================
pub async fn auth(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["login"]) => login(state, req).await,
("POST", ["logout"]) => logout(state, req),
("GET" | "HEAD", ["me"]) => me(state, req),
_ => http::not_found(),
}
}
async fn login(state: &SharedState, req: &Req) -> Resp {
println!("[Auth] Login attempt received");
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => {
println!("[Auth] ERROR during login: invalid body");
return resp;
}
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
println!("[Auth] Missing username or password");
return error_json(400, "Username and password are required");
};
println!("[Auth] Verifying credentials for user: {username}");
// bcrypt est coûteux : hors du runtime asynchrone.
let db = state.db.clone();
let user_name = username.clone();
let user = tokio::task::spawn_blocking(move || db.verify_credentials(&user_name, &pass))
.await
.ok()
.flatten();
let Some(user) = user else {
println!("[Auth] Invalid credentials for user: {username}");
return error_json(401, "Invalid credentials");
};
println!(
"[Auth] User verified, creating session for userId: {}",
user.id
);
let session = match state.db.create_session(&user.id) {
Ok(s) => s,
Err(e) => return error_json(500, e.to_string()),
};
// Cookie de session HttpOnly : les requêtes média de même origine
// (hls.js dans l'iframe du lecteur ne peut pas envoyer d'en-tête
// Authorization) sont ainsi authentifiées.
let max_age = (session.expires_at.to_utc() - chrono::Utc::now()).num_seconds();
let mut resp = json(
200,
&json!({"success": true, "user": user.to_json(), "token": session.token}),
);
http::set_header(
&mut resp,
"set-cookie",
&format!(
"session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}",
session.token
),
);
resp
}
fn logout(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
state.db.delete_session(&token);
let mut resp = json(200, &json!({"success": true}));
http::set_header(
&mut resp,
"set-cookie",
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
resp
}
fn me(state: &SharedState, req: &Req) -> Resp {
let Some(token) = extract_token(req) else {
return error_json(401, "Unauthorized");
};
let Some(session) = state.db.find_session_by_token(&token) else {
return error_json(401, "Invalid or expired session");
};
match state.db.find_user_by_id(&session.user_id) {
Some(user) => json(200, &json!({"user": user.to_json()})),
None => error_json(404, "User not found"),
}
}
// ==================== /api/users ====================
fn is_admin(state: &SharedState, req: &Req) -> bool {
req.user_id
.as_deref()
.and_then(|id| state.db.find_user_by_id(id))
.is_some_and(|u| u.is_admin)
}
fn admin_required() -> Resp {
json_as_text(403, &json!({"success": false, "error": "Admin required"}))
}
pub async fn users(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list_users(state, req),
("POST", []) => create_user(state, req).await,
("PUT", [id, "password"]) => update_password(state, req, id).await,
("PUT", [id]) => update_user(state, req, id),
("DELETE", [id]) => delete_user(state, req, id),
_ => http::not_found(),
}
}
fn list_users(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let users: Vec<Value> = state
.db
.get_all_users()
.iter()
.map(|u| u.to_json())
.collect();
json(200, &json!({"success": true, "users": users}))
}
async fn create_user(state: &SharedState, req: &Req) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let (Some(username), Some(pass)) = (
str_field(&payload, "username"),
str_field(&payload, "password"),
) else {
return error_json(400, "Missing required fields");
};
let admin = payload
.get("isAdmin")
.and_then(Value::as_bool)
.unwrap_or(false);
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
if state.db.find_user_by_username(&username).is_some() {
return error_json(400, "Username already exists");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
match state.db.create_user(&username, &hash, admin) {
Ok(user) => json(200, &json!({"success": true, "user": user.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
async fn update_password(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let Some(pass) = str_field(&payload, "password") else {
return error_json(400, "Missing password");
};
if dart_len(&pass) < 8 {
return error_json(400, "Le mot de passe doit faire au moins 8 caractères");
}
let hash = match tokio::task::spawn_blocking(move || password::hash(&pass)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return error_json(500, e),
Err(e) => return error_json(500, e.to_string()),
};
state.db.update_password_hash(id, &hash);
json(200, &json!({"success": true}))
}
fn update_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
if let Some(admin) = payload.get("isAdmin").and_then(Value::as_bool)
&& let Err(e) = state.db.update_user_admin_status(id, admin)
{
return error_json(500, e.to_string());
}
json(200, &json!({"success": true}))
}
fn delete_user(state: &SharedState, req: &Req, id: &str) -> Resp {
if !is_admin(state, req) {
return admin_required();
}
// Interdit de se supprimer soi-même.
if req.user_id.as_deref() == Some(id) {
return error_json(400, "Cannot delete yourself");
}
match state.db.delete_user(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/playlists ====================
pub fn playlists(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return error_json(401, "Unauthorized");
};
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let list: Vec<Value> = state
.db
.get_playlists(&user_id)
.iter()
.map(|p| p.to_json())
.collect();
json(200, &json!({"playlists": list}))
}
("POST", []) => create_playlist(state, req, &user_id),
("PUT", [id]) => update_playlist(state, req, &user_id, id),
("DELETE", [id]) => delete_playlist(state, &user_id, id),
_ => http::not_found(),
}
}
fn create_playlist(state: &SharedState, req: &Req, user_id: &str) -> Resp {
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let (Some(name), Some(server_url), Some(username), Some(pass)) = (
field("name"),
field("serverUrl"),
field("username"),
field("password"),
) else {
return error_json(400, "Missing required fields");
};
let dns = field("dns").unwrap_or_else(|| server_url.clone());
match state
.db
.create_playlist(user_id, &name, &server_url, &username, &pass, Some(&dns))
{
Ok(p) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Err(e) => error_json(500, e.to_string()),
}
}
fn update_playlist(state: &SharedState, req: &Req, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
let payload = match parse_object(req) {
Ok(p) => p,
Err(resp) => return resp,
};
let field = |k| str_field(&payload, k);
let name = field("name").unwrap_or(existing.name.clone());
let server_url = field("serverUrl").unwrap_or(existing.server_url.clone());
let username = field("username").unwrap_or(existing.username.clone());
// Mot de passe vide = « inchangé » : les clients ne reçoivent jamais le
// vrai mot de passe, les formulaires d'édition le renvoient vide.
let pass = field("password")
.filter(|p| !p.is_empty())
.unwrap_or(existing.password.clone());
let dns = field("dns").or(existing.dns.clone());
match state
.db
.update_playlist(id, &name, &server_url, &username, &pass, dns.as_deref())
{
Ok(Some(p)) => json(200, &json!({"success": true, "playlist": p.to_json()})),
Ok(None) => error_json(404, "Playlist not found"),
Err(e) => error_json(500, e.to_string()),
}
}
fn delete_playlist(state: &SharedState, user_id: &str, id: &str) -> Resp {
let Some(existing) = state.db.get_playlist_by_id(id) else {
return error_json(404, "Playlist not found");
};
if existing.user_id != user_id {
return error_json(403, "Forbidden");
}
match state.db.delete_playlist(id) {
Ok(()) => json(200, &json!({"success": true})),
Err(e) => error_json(500, e.to_string()),
}
}
// ==================== /api/settings ====================
pub fn settings(state: &SharedState, req: &Req, rest: &str) -> Resp {
let Some(user_id) = req.user_id.clone() else {
return json_as_text(403, &json!({"error": "Authentication required"}));
};
if !segments(rest).is_empty() {
return http::not_found();
}
match req.method.as_str() {
"GET" | "HEAD" => {
let settings = state
.db
.get_user_settings(&user_id)
.unwrap_or_else(|| "{}".to_string());
http::with_headers(
http::response(200, settings),
&[("content-type", "application/json")],
)
}
"POST" => {
let body = req.body_str();
if serde_json::from_str::<Value>(&body).is_err() {
return json_as_text(400, &json!({"error": "Invalid JSON"}));
}
if let Err(e) = state.db.update_user_settings(&user_id, &body) {
return text(
500,
json!({"error": format!("Failed to save settings: {e}")}).to_string(),
);
}
http::with_headers(
http::response(200, body),
&[("content-type", "application/json")],
)
}
_ => http::not_found(),
}
}
+194
View File
@@ -0,0 +1,194 @@
//! `/api/admin` : purge des temporaires et diagnostic du délai de zap.
use crate::http::{self, Req, Resp, json_as_text, segments};
use crate::models::PlaylistConfig;
use crate::state::SharedState;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
pub async fn admin(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("POST", ["purge"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
http::json(200, &state.cleanup.run_cleanup().await)
}
("GET" | "HEAD", ["upstream-probe"]) => {
if !req.user.as_ref().is_some_and(|u| u.is_admin) {
return json_as_text(403, &json!({"error": "Admin access required"}));
}
let stream = req.query_param("stream").unwrap_or_default();
if stream.is_empty() || !stream.bytes().all(|b| b.is_ascii_digit()) {
return http::text(400, "stream=<id numérique> requis");
}
let Some(playlist) = state.get_playlist(req) else {
return http::text(403, "No playlist");
};
http::json(
200,
&probe_upstream(&state.http_manual, &playlist, &stream).await,
)
}
_ => http::not_found(),
}
}
/// Diagnostic admin : chronomètre l'ouverture d'une chaîne chez le
/// fournisseur, en `.ts` et en `.m3u8`.
///
/// POURQUOI : ~4,3 s des ~4,6 s d'un zap se passent avant le premier octet
/// du panneau (mesuré en prod). Reste à savoir lequel des deux formats
/// démarre le plus vite, et où part le temps. Les identifiants n'apparaissent
/// jamais dans la réponse : seuls l'hôte et les durées sont rendus.
///
/// [client] ne doit PAS suivre les redirections (chaque saut est chronométré).
pub async fn probe_upstream(
client: &reqwest::Client,
playlist: &PlaylistConfig,
stream_id: &str,
) -> Value {
let base = format!(
"{}/live/{}/{}/{stream_id}",
playlist.dns, playlist.username, playlist.password
);
let ts = match reqwest::Url::parse(&format!("{base}.ts")) {
Ok(url) => probe_stream(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
// Laisser le panneau libérer la connexion (comptes à une connexion).
tokio::time::sleep(Duration::from_secs(2)).await;
let hls = match reqwest::Url::parse(&format!("{base}.m3u8")) {
Ok(url) => probe_hls(client, url).await,
Err(_) => json!({"error": "FormatException", "afterMs": 0}),
};
json!({"stream": stream_id, "ts": ts, "m3u8": hls})
}
/// Suit les redirections à la main pour chronométrer chaque saut.
async fn open(
client: &reqwest::Client,
url: reqwest::Url,
started: Instant,
) -> Result<(Option<reqwest::Response>, Vec<Value>, reqwest::Url), String> {
let mut hops = Vec::new();
let mut current = url;
for _ in 0..5 {
let send = client
.get(current.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.send();
let response = tokio::time::timeout(Duration::from_secs(20), send)
.await
.map_err(|_| "TimeoutException".to_string())?
.map_err(|_| "ClientException".to_string())?;
let status = response.status().as_u16();
hops.push(json!({
"host": current.host_str().unwrap_or(""),
"status": status,
"headersMs": started.elapsed().as_millis() as u64,
}));
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let (true, Some(location)) = ([301, 302, 303, 307, 308].contains(&status), location) {
drop(response);
current = current
.join(&location)
.map_err(|_| "FormatException".to_string())?;
continue;
}
return Ok((Some(response), hops, current));
}
Ok((None, hops, current))
}
fn status_error(response: Option<&reqwest::Response>) -> String {
match response {
Some(r) => format!("status {}", r.status().as_u16()),
None => "status null".to_string(),
}
}
async fn probe_stream(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, _) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let mut response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let mut bytes = 0usize;
let mut first_byte_ms: Option<u64> = None;
// 1 Mo suffit à voir le premier envoi (préchargement du panneau).
let read = async {
while let Ok(Some(chunk)) = response.chunk().await {
first_byte_ms.get_or_insert(started.elapsed().as_millis() as u64);
bytes += chunk.len();
if bytes > 1_000_000 {
break;
}
}
};
let _ = tokio::time::timeout(Duration::from_secs(15), read).await;
json!({
"hops": hops,
"firstByteMs": first_byte_ms,
"firstMegabyteMs": (bytes > 1_000_000).then(|| started.elapsed().as_millis() as u64),
})
}
async fn probe_hls(client: &reqwest::Client, url: reqwest::Url) -> Value {
let started = Instant::now();
let (response, hops, final_url) = match open(client, url, started).await {
Ok(r) => r,
Err(e) => return json!({"error": e, "afterMs": started.elapsed().as_millis() as u64}),
};
let response = match response {
Some(r) if r.status().as_u16() == 200 => r,
other => return json!({"hops": hops, "error": status_error(other.as_ref())}),
};
let body = match response.text().await {
Ok(b) => b,
Err(_) => {
return json!({"error": "ClientException", "afterMs": started.elapsed().as_millis() as u64});
}
};
let playlist_ms = started.elapsed().as_millis() as u64;
let lines: Vec<&str> = body.lines().collect();
let segments: Vec<&str> = lines
.iter()
.copied()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
let target = lines
.iter()
.find(|l| l.starts_with("#EXT-X-TARGETDURATION"))
.map(|l| l.rsplit(':').next().unwrap_or("").to_string())
.unwrap_or_default();
if segments.is_empty() {
return json!({"hops": hops, "playlistMs": playlist_ms, "segments": 0});
}
// Un lecteur démarre près du direct : chronométrer l'avant-dernier.
let pick = if segments.len() >= 2 {
segments[segments.len() - 2]
} else {
segments[segments.len() - 1]
};
let segment = match final_url.join(pick) {
Ok(url) => Box::pin(probe_stream(client, url)).await,
Err(_) => json!({"error": "FormatException"}),
};
json!({
"hops": hops,
"playlistMs": playlist_ms,
"segments": segments.len(),
"targetDuration": target,
"segment": segment,
"segmentStartedAtMs": playlist_ms,
})
}
+774
View File
@@ -0,0 +1,774 @@
//! API EPG — `GET /api/epg/<channel_id>`, cache 30 minutes.
//!
//! Le panneau de l'abonné reste la source de référence, mais par son dump
//! `xmltv.php` d'abord : une requête couvre toutes les chaînes, là où
//! `player_api` en réclame une par chaîne à plusieurs secondes pièce. Vient
//! ensuite la source XMLTV externe (déjà indexée en mémoire), puis
//! l'interrogation chaîne par chaîne — cas des revendeurs dont le guide est
//! figé depuis plusieurs jours.
use crate::dart_time::{DartDateTime, utc_iso};
use crate::http::{self, Resp};
use crate::models::PlaylistConfig;
use crate::services::xmltv::{XmltvConfig, XmltvEpgService};
use crate::util::b64;
use crate::util::log_redactor::redact_url;
use chrono::{TimeZone, Utc};
use futures::FutureExt;
use futures::future::{BoxFuture, Shared};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub type PanelXmltvBuilder = Arc<dyn Fn(&PlaylistConfig) -> XmltvEpgService + Send + Sync>;
/// Correspondance `stream_id` → identifiant EPG et nom, par compte. Le dump
/// XMLTV indexe par identifiant de chaîne, pas par `stream_id` du panneau.
#[derive(Default)]
struct ChannelMap {
epg_ids: HashMap<String, String>,
names: HashMap<String, String>,
expires_at: Option<Instant>,
}
struct Inner {
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
/// « dns|username|channelId » → réponse JSON. La clé inclut le compte :
/// deux panneaux peuvent partager un même `stream_id`.
cache: Mutex<HashMap<String, (String, Instant)>>,
channel_maps: Mutex<HashMap<String, Arc<ChannelMap>>>,
channel_maps_in_flight: Mutex<HashMap<String, Shared<BoxFuture<'static, Arc<ChannelMap>>>>>,
panel_xmltv: Mutex<HashMap<String, Arc<XmltvEpgService>>>,
}
#[derive(Clone)]
pub struct EpgApi {
inner: Arc<Inner>,
}
/// Dump XMLTV servi par le panneau lui-même.
///
/// POURQUOI : `get_simple_data_table` répond en plusieurs secondes chez
/// beaucoup de revendeurs, et il faut un appel par chaîne — une grille de
/// trente chaînes demandait plus d'une minute. `xmltv.php` renvoie le guide
/// de toutes les chaînes en une requête, réutilisée pendant des heures.
pub fn default_panel_xmltv(client: reqwest::Client) -> PanelXmltvBuilder {
Arc::new(move |playlist: &PlaylistConfig| {
let enc = |s: &str| url::form_urlencoded::byte_serialize(s.as_bytes()).collect::<String>();
let mut config = XmltvConfig::new(vec![format!(
"{}/xmltv.php?username={}&password={}",
playlist.dns,
enc(&playlist.username),
enc(&playlist.password)
)]);
config.refresh_interval = Duration::from_secs(3 * 3600);
// Guide facultatif : pas plusieurs minutes d'attente sur un panneau lent.
config.download_timeout = Duration::from_secs(90);
XmltvEpgService::new(config, client.clone())
})
}
fn value_to_string(v: &Value) -> Option<String> {
match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
/// `stream_id` → (`epg_channel_id`, nom) depuis `get_live_streams`.
fn parse_channel_table(body: &[u8]) -> (HashMap<String, String>, HashMap<String, String>) {
let mut epg_ids = HashMap::new();
let mut names = HashMap::new();
// UTF-8 explicite : sans charset annoncé, un décodage latin-1 abîmait
// les noms (« Chérie », « ◉ ») et donc leur correspondance au guide.
let text = String::from_utf8_lossy(body);
if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(&text) {
for item in items {
let Value::Object(item) = item else { continue };
let Some(stream_id) = item.get("stream_id").and_then(value_to_string) else {
continue;
};
if stream_id.is_empty() {
continue;
}
if let Some(epg_id) = item.get("epg_channel_id").and_then(value_to_string)
&& !epg_id.is_empty()
{
epg_ids.insert(stream_id.clone(), epg_id);
}
if let Some(name) = item.get("name").and_then(value_to_string)
&& !name.is_empty()
{
names.insert(stream_id, name);
}
}
}
(epg_ids, names)
}
/// Epoch (secondes, texte ou entier) → ISO-8601 UTC.
fn iso_utc(timestamp: Option<&Value>) -> Option<String> {
let seconds: i64 = match timestamp? {
Value::Number(n) => n.as_i64()?,
Value::String(s) => s.parse().ok()?,
_ => return None,
};
if seconds <= 0 {
return None;
}
Utc.timestamp_opt(seconds, 0).single().map(utc_iso)
}
/// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ».
fn normalize_date(raw: &str) -> String {
if raw.contains(' ') && !raw.contains('T') {
raw.replacen(' ', "T", 1)
} else {
raw.to_string()
}
}
/// Normalise une réponse Xtream (`epg_listings`) vers le format servi.
pub fn transform_epg_data(raw: &Value, channel_id: &str) -> Value {
let listings: Vec<Value> = match raw {
Value::Object(map) if map.contains_key("epg_listings") => map
.get("epg_listings")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
Value::Array(list) => list.clone(),
_ => Vec::new(),
};
let str_of = |item: &Map<String, Value>, key: &str| -> String {
item.get(key)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let mut programmes = Vec::new();
for item in listings {
let Value::Object(item) = item else {
return json!({"channel_id": channel_id, "programmes": [], "error": "EPG indisponible"});
};
// Les champs texte `start`/`end` sont dans le fuseau du panneau, sans
// indicateur de zone : le client les relisait en heure locale et
// décalait tout le guide. Les `*_timestamp` sont de l'epoch UTC.
let start = iso_utc(item.get("start_timestamp"))
.unwrap_or_else(|| normalize_date(&str_of(&item, "start")));
let end = iso_utc(item.get("stop_timestamp"))
.or_else(|| iso_utc(item.get("end_timestamp")))
.unwrap_or_else(|| {
let raw = item
.get("stop")
.and_then(Value::as_str)
.or_else(|| item.get("end").and_then(Value::as_str))
.unwrap_or("");
normalize_date(raw)
});
let decode = |raw: String| -> String {
if raw.is_empty() {
return raw;
}
match b64::decode_text(&raw) {
Some(decoded) if !decoded.is_empty() => decoded,
_ => raw,
}
};
programmes.push(json!({
"title": decode(str_of(&item, "title")),
"description": decode(str_of(&item, "description")),
"start": start,
"end": end,
"channel_id": channel_id,
}));
}
json!({"channel_id": channel_id, "programmes": programmes})
}
/// Le guide contient-il un programme en cours ou à venir ? Un panneau au
/// guide figé répond avec des centaines de programmes tous terminés :
/// compter les entrées ne suffit pas.
fn has_current_programme(programmes: &[Value]) -> bool {
let now = Utc::now();
programmes.iter().any(|p| {
p.get("end")
.and_then(Value::as_str)
.and_then(DartDateTime::parse)
.is_some_and(|end| end.to_utc() > now)
})
}
impl EpgApi {
pub fn new(
xmltv: Option<Arc<XmltvEpgService>>,
http: reqwest::Client,
panel_builder: PanelXmltvBuilder,
) -> Self {
Self {
inner: Arc::new(Inner {
xmltv,
http,
panel_builder,
cache: Mutex::default(),
channel_maps: Mutex::default(),
channel_maps_in_flight: Mutex::default(),
panel_xmltv: Mutex::default(),
}),
}
}
pub async fn handle(&self, playlist: Option<PlaylistConfig>, channel_id: &str) -> Resp {
let Some(playlist) = playlist else {
return http::json(403, &json!({"error": "Playlist non trouvée"}));
};
let cache_key = format!("{}|{}|{channel_id}", playlist.dns, playlist.username);
if let Some((data, expires)) = self.inner.cache.lock().unwrap().get(&cache_key)
&& Instant::now() < *expires
{
return http::with_headers(
http::response(200, data.clone()),
&[("content-type", "application/json"), ("x-cache", "HIT")],
);
}
// Sources par ordre de préférence. On s'arrête à la première qui
// contient un programme en cours ou à venir ; à défaut, la première
// non vide sert de repli.
let mut programmes: Vec<Value> = Vec::new();
let mut source = "xtream";
let mut found = false;
for name in ["panel-xmltv", "xmltv", "xtream"] {
let candidate = match name {
"panel-xmltv" => self.panel_programmes(&playlist, channel_id).await,
"xmltv" => {
self.programmes_from_xmltv(self.inner.xmltv.clone(), &playlist, channel_id)
.await
}
_ => self.xtream_programmes(&playlist, channel_id).await,
};
if candidate.is_empty() {
continue;
}
let current = has_current_programme(&candidate);
if !found || current {
programmes = candidate;
source = name;
found = true;
}
if current {
break;
}
}
let body = json!({"channel_id": channel_id, "programmes": programmes}).to_string();
// 30 min pour un guide trouvé ; 5 min pour un guide vide, qui vient
// souvent d'une source momentanément indisponible.
let ttl = Duration::from_secs(if found { 30 * 60 } else { 5 * 60 });
self.inner
.cache
.lock()
.unwrap()
.insert(cache_key, (body.clone(), Instant::now() + ttl));
http::with_headers(
http::response(200, body),
&[
("content-type", "application/json"),
("x-cache", "MISS"),
("x-epg-source", source),
],
)
}
async fn panel_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
let key = format!("{}|{}", playlist.dns, playlist.username);
let service = self
.inner
.panel_xmltv
.lock()
.unwrap()
.entry(key)
.or_insert_with(|| Arc::new((self.inner.panel_builder)(playlist)))
.clone();
self.programmes_from_xmltv(Some(service), playlist, channel_id)
.await
}
/// Programmes d'une chaîne dans un index XMLTV (panneau ou externe).
async fn programmes_from_xmltv(
&self,
xmltv: Option<Arc<XmltvEpgService>>,
playlist: &PlaylistConfig,
channel_id: &str,
) -> Vec<Value> {
let Some(xmltv) = xmltv else {
return Vec::new();
};
let channels = self.channel_map_for(playlist).await;
let epg_id = channels.epg_ids.get(channel_id).map(String::as_str);
let name = channels.names.get(channel_id).map(String::as_str);
if epg_id.is_none() && name.is_none() {
return Vec::new();
}
let now = Utc::now();
xmltv
.programmes_for(epg_id, name)
.await
.iter()
.filter(|p| p.stop > now)
.take(64)
.map(|p| p.to_json(channel_id))
.collect()
}
/// Interrogation du panneau chaîne par chaîne : chemin lent, plusieurs
/// secondes par action, réservé aux chaînes absentes des dumps.
async fn xtream_programmes(&self, playlist: &PlaylistConfig, channel_id: &str) -> Vec<Value> {
// `get_simple_data_table` d'abord : seule action réellement
// universelle. `get_epg` manque sur beaucoup de panneaux, qui
// renvoient alors le payload d'authentification.
for action in ["get_simple_data_table", "get_short_epg"] {
let url = format!(
"{}/player_api.php?username={}&password={}&action={action}&stream_id={channel_id}",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = self.inner.http.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (status, body) = match tokio::time::timeout(Duration::from_secs(20), fetch).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
// Une source parmi d'autres, pas une raison de répondre 500.
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : {}",
redact_url(&format!("{e:?}"))
);
continue;
}
Err(_) => {
println!(
"[EpgApi] player_api {action} indisponible pour {channel_id} : TimeoutException after 0:00:20.000000"
);
continue;
}
};
if status != 200 {
continue;
}
let Ok(raw) = serde_json::from_str::<Value>(&String::from_utf8_lossy(&body)) else {
continue;
};
let programmes: Vec<Value> = transform_epg_data(&raw, channel_id)["programmes"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(Value::is_object)
.collect();
if !programmes.is_empty() {
return programmes;
}
}
Vec::new()
}
/// Table des chaînes du compte, rafraîchie toutes les 6 heures. Les
/// requêtes simultanées partagent un seul téléchargement : la grille
/// demande le guide de 30 chaînes d'un coup (7 Mo de table en prod).
async fn channel_map_for(&self, playlist: &PlaylistConfig) -> Arc<ChannelMap> {
let key = format!("{}|{}", playlist.dns, playlist.username);
if let Some(map) = self.inner.channel_maps.lock().unwrap().get(&key)
&& map.expires_at.is_some_and(|e| Instant::now() < e)
{
return map.clone();
}
let shared = {
let mut in_flight = self.inner.channel_maps_in_flight.lock().unwrap();
match in_flight.get(&key) {
Some(f) => f.clone(),
None => {
let inner = self.inner.clone();
let playlist = playlist.clone();
let task_key = key.clone();
// Tâche détachée : le téléchargement aboutit même si le
// client qui l'a déclenché est parti.
let handle = tokio::spawn(async move {
let map = Arc::new(load_channel_map(&inner.http, &playlist).await);
inner
.channel_maps
.lock()
.unwrap()
.insert(task_key.clone(), map.clone());
inner
.channel_maps_in_flight
.lock()
.unwrap()
.remove(&task_key);
map
});
let f = async move { handle.await.unwrap_or_default() }
.boxed()
.shared();
in_flight.insert(key, f.clone());
f
}
}
};
shared.await
}
}
async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) -> ChannelMap {
let url = format!(
"{}/player_api.php?username={}&password={}&action=get_live_streams",
playlist.dns, playlist.username, playlist.password
);
let fetch = async {
let response = client.get(&url).send().await?;
let status = response.status().as_u16();
Ok::<_, reqwest::Error>((status, response.bytes().await?))
};
let (mut epg_ids, mut names) = (HashMap::new(), HashMap::new());
match tokio::time::timeout(Duration::from_secs(90), fetch).await {
Ok(Ok((200, body))) => {
// Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais
// des flux en cours.
match tokio::task::spawn_blocking(move || parse_channel_table(&body)).await {
Ok(parsed) => (epg_ids, names) = parsed,
Err(e) => println!("[EpgApi] table des chaînes illisible : {e}"),
}
}
Ok(Ok(_)) => {}
Ok(Err(e)) => println!(
"[EpgApi] table des chaînes indisponible : {}",
redact_url(&format!("{e:?}"))
),
Err(_) => println!(
"[EpgApi] table des chaînes indisponible : TimeoutException after 0:01:30.000000"
),
}
// Une table vide (panneau en échec) n'est gardée que 2 minutes : la
// garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h.
let ttl = if names.is_empty() {
Duration::from_secs(120)
} else {
Duration::from_secs(6 * 3600)
};
ChannelMap {
epg_ids,
names,
expires_at: Some(Instant::now() + ttl),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::{Query, Request};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn convertit_les_epoch_en_utc_et_decode_le_base64() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"end": "2026-08-11 20:35:00",
"description": "TCdpbmZvIGR1IHNvaXI=",
"start_timestamp": "1786471200",
"stop_timestamp": "1786473300",
}]});
let result = transform_epg_data(&raw, "845452");
let p = &result["programmes"][0];
assert_eq!(p["start"], "2026-08-11T18:00:00.000Z");
assert_eq!(p["end"], "2026-08-11T18:35:00.000Z");
assert_eq!(p["title"], "Journal de 20h");
assert_eq!(p["description"], "L'info du soir");
assert_eq!(p["channel_id"], "845452");
}
#[test]
fn retombe_sur_les_champs_texte() {
let raw = json!({"epg_listings": [{
"title": "Sm91cm5hbCBkZSAyMGg=",
"start": "2026-08-11 20:00:00",
"stop": "2026-08-11 20:35:00",
}]});
let p = &transform_epg_data(&raw, "845452")["programmes"][0];
assert_eq!(p["start"], "2026-08-11T20:00:00");
assert_eq!(p["end"], "2026-08-11T20:35:00");
}
#[test]
fn liste_vide_sans_epg_listings() {
let raw = json!({"user_info": {"auth": 1}, "server_info": {"url": "x"}});
assert_eq!(transform_epg_data(&raw, "1")["programmes"], json!([]));
}
// ---------- handle() contre un faux panneau ----------
type Handler = Arc<dyn Fn(&str, &HashMap<String, String>) -> (u16, String) + Send + Sync>;
async fn mock(handler: Handler) -> String {
let app = axum::Router::new().fallback(
move |Query(q): Query<HashMap<String, String>>, req: Request| {
let handler = handler.clone();
async move {
// Laisser respirer les requêtes concurrentes (mutualisation).
tokio::time::sleep(Duration::from_millis(30)).await;
let (status, body) = handler(req.uri().path(), &q);
(axum::http::StatusCode::from_u16(status).unwrap(), body)
}
},
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
fn stamp(d: chrono::DateTime<Utc>) -> String {
d.format("%Y%m%d%H%M%S +0000").to_string()
}
fn dump(
id: &str,
name: &str,
start: chrono::DateTime<Utc>,
stop: chrono::DateTime<Utc>,
) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8" ?><tv><channel id="{id}"><display-name>{name}</display-name></channel><programme start="{}" stop="{}" channel="{id}"><title lang="fr">Journal de 20h</title><desc lang="fr">L'info du soir</desc></programme></tv>"#,
stamp(start),
stamp(stop)
)
}
fn playlist(base: &str) -> PlaylistConfig {
PlaylistConfig {
dns: base.to_string(),
username: "u".into(),
password: "p".into(),
}
}
fn api(base: &str, external: bool) -> EpgApi {
let client = reqwest::Client::new();
let xmltv = external.then(|| {
Arc::new(XmltvEpgService::new(
XmltvConfig::new(vec![format!("{base}/ext/epg.xml")]),
client.clone(),
))
});
let c = client.clone();
let builder: PanelXmltvBuilder = Arc::new(move |p: &PlaylistConfig| {
XmltvEpgService::new(
XmltvConfig::new(vec![format!("{}/xmltv.php", p.dns)]),
c.clone(),
)
});
EpgApi::new(xmltv, client, builder)
}
async fn body_json(resp: Resp) -> Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn sert_le_dump_du_panneau_sans_appel_par_chaine() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, dump("France2.fr", "FRANCE 2", now - chrono::Duration::minutes(5), now + chrono::Duration::minutes(25)));
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (200, json!([{"stream_id": 845452, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}]).to_string());
}
(200, "{}".into())
}))
.await;
let resp = api(&base, false)
.handle(Some(playlist(&base)), "845452")
.await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv");
let body = body_json(resp).await;
assert_eq!(body["programmes"][0]["title"], "Journal de 20h");
assert_eq!(*actions.lock().unwrap(), vec!["get_live_streams"]);
}
#[tokio::test]
async fn retombe_sur_player_api_quand_le_dump_ne_couvre_pas_la_chaine() {
let future = chrono::Local::now() + chrono::Duration::minutes(10);
let panel_stamp =
|d: chrono::DateTime<chrono::Local>| d.format("%Y-%m-%d %H:%M:%S").to_string();
let start = panel_stamp(future);
let end = panel_stamp(future + chrono::Duration::hours(2));
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
match q.get("action").map(String::as_str) {
Some("get_live_streams") => (200, "[]".into()),
Some("get_simple_data_table") => (
200,
json!({"epg_listings": [{"title": "TWF0Y2g=", "description": "", "start": start, "end": end}]}).to_string(),
),
_ => (200, "{}".into()),
}
}))
.await;
let resp = api(&base, false).handle(Some(playlist(&base)), "999").await;
assert_eq!(resp.headers()["x-epg-source"], "xtream");
assert_eq!(body_json(resp).await["programmes"][0]["title"], "Match");
}
#[tokio::test]
async fn un_panneau_qui_plante_ne_prive_pas_du_dump_externe() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 1, "name": "FR - TF1 FHD ◉", "epg_channel_id": ""}])
.to_string(),
);
}
(500, "boom".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert_eq!(
body_json(resp).await["programmes"][0]["title"],
"Journal de 20h"
);
}
#[tokio::test]
async fn le_dump_externe_passe_avant_l_appel_lent() {
let now = Utc::now();
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let a = actions.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
let action = q.get("action").cloned().unwrap_or_default();
a.lock().unwrap().push(action.clone());
if action == "get_live_streams" {
return (
200,
json!([{"stream_id": 1, "name": "TF1", "epg_channel_id": "TF1.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let resp = api(&base, true).handle(Some(playlist(&base)), "1").await;
assert_eq!(resp.headers()["x-epg-source"], "xmltv");
assert!(
!actions
.lock()
.unwrap()
.contains(&"get_simple_data_table".to_string())
);
}
#[tokio::test]
async fn la_grille_partage_un_seul_telechargement_de_la_table() {
let now = Utc::now();
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let base = mock(Arc::new(move |path, q| {
if path == "/ext/epg.xml" {
return (
200,
dump("TF1.fr", "TF1", now, now + chrono::Duration::hours(1)),
);
}
if path.ends_with("/xmltv.php") {
return (200, "<tv></tv>".into());
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
c.fetch_add(1, Ordering::SeqCst);
let list: Vec<Value> = (1..=5)
.map(|i| json!({"stream_id": i, "name": "TF1", "epg_channel_id": "TF1.fr"}))
.collect();
return (200, Value::Array(list).to_string());
}
(200, "{}".into())
}))
.await;
let api = api(&base, true);
let ids: Vec<String> = (1..=5).map(|i| i.to_string()).collect();
let responses =
futures::future::join_all(ids.iter().map(|id| api.handle(Some(playlist(&base)), id)))
.await;
assert!(responses.iter().all(|r| r.status().as_u16() == 200));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cache_hit_au_second_appel() {
let now = Utc::now();
let base = mock(Arc::new(move |path, q| {
if path.ends_with("/xmltv.php") {
return (
200,
dump(
"France2.fr",
"FRANCE 2",
now,
now + chrono::Duration::minutes(25),
),
);
}
if q.get("action").map(String::as_str) == Some("get_live_streams") {
return (
200,
json!([{"stream_id": 7, "name": "FRANCE 2", "epg_channel_id": "France2.fr"}])
.to_string(),
);
}
(200, "{}".into())
}))
.await;
let api = api(&base, false);
let first = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(first.headers()["x-cache"], "MISS");
let second = api.handle(Some(playlist(&base)), "7").await;
assert_eq!(second.headers()["x-cache"], "HIT");
assert!(api.handle(None, "7").await.status().as_u16() == 403);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! `GET /api/logo?src=<stream_icon>&name=<nom de chaîne>`
//!
//! Logo d'une chaîne, avec repli. L'URL du panneau est tentée d'abord, via
//! le proxy Xtream (mêmes protections anti-SSRF, même mémoire des hôtes
//! morts). En cas d'échec, le logo est cherché par nom dans le dépôt public
//! `tv-logos`.
//!
//! POURQUOI : quand l'hébergeur de picons du revendeur tombe, toute la grille
//! perd ses logos d'un coup, alors que les autres applications IPTV les
//! affichent encore grâce à leurs propres sources de repli.
use crate::api::proxy::missing_image_response;
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::http::{HeaderMap, Method};
/// Un logo trouvé change rarement : un jour de cache navigateur.
const FOUND: &str = "public, max-age=86400";
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let params = req.query_params();
let src = params.get("src").cloned().unwrap_or_default();
let name = params.get("name").cloned().unwrap_or_default();
if src.starts_with("http://") || src.starts_with("https://") {
let (path, query) = match src.split_once('?') {
Some((p, q)) => (p, q),
None => (src.as_str(), ""),
};
let path = path.split('#').next().unwrap_or("");
let query = query.split('#').next().unwrap_or("");
// Requête interne vers le proxy, avec la seule session du client.
let mut headers = HeaderMap::new();
for header in ["cookie", "authorization"] {
if let Some(value) = req.headers.get(header) {
headers.insert(header, value.clone());
}
}
let proxied = Req {
method: Method::GET,
path: format!("api/xtream/{path}"),
query: query.to_string(),
headers,
body: Default::default(),
remote: req.remote,
user: None,
user_id: None,
};
let upstream = state.proxy.handle(state, &proxied).await;
let content_type = upstream
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if upstream.status().as_u16() == 200 && content_type.starts_with("image/") {
return http::with_headers(
http::response(200, upstream.into_body()),
&[("content-type", &content_type), ("cache-control", FOUND)],
);
}
// Réponse abandonnée : la connexion amont est refermée.
drop(upstream);
}
if !name.is_empty()
&& let Some(bytes) = state.logo_catalog.logo_for(&name).await
{
return http::with_headers(
http::response(200, bytes),
&[("content-type", "image/png"), ("cache-control", FOUND)],
);
}
missing_image_response()
}
+11
View File
@@ -0,0 +1,11 @@
pub mod account;
pub mod admin;
pub mod epg;
pub mod logo;
pub mod player_log;
pub mod proxy;
pub mod recording_playlist;
pub mod recordings;
pub mod static_files;
pub mod streaming;
pub mod xtream_api;
+135
View File
@@ -0,0 +1,135 @@
//! 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"));
}
}
+466
View File
@@ -0,0 +1,466 @@
//! Proxy Xtream `/api/xtream/<url>` (images, API du panneau) avec
//! protections anti-SSRF, et mémoire des hôtes d'images hors service.
use crate::http::{self, Req, Resp, extract_token};
use crate::models::PlaylistConfig;
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// Réponse pour une image indisponible.
///
/// Statut 410 et non 404 : la cascade du serveur retombe sur l'étage suivant
/// pour tout 404 et renverrait finalement celui des fichiers statiques, sans
/// notre `cache-control`. Un 410 s'arrête ici.
///
/// Un statut d'erreur plutôt qu'une image vide : le client affiche alors son
/// propre repli (icône, initiale) au lieu d'une tuile blanche. Le `max-age`
/// est essentiel : sans lui le navigateur redemande le logo à chaque
/// affichage de la grille.
pub fn missing_image_response() -> Resp {
http::with_headers(
http::empty(410),
&[
("cache-control", "public, max-age=600"),
("access-control-allow-origin", "*"),
],
)
}
/// Mémoire courte des hôtes d'images injoignables.
///
/// POURQUOI : les URL de picons pointent chez l'hébergeur du revendeur, pas
/// sur le panneau. Quand cette machine tombe, elle répond 5xx en quelques
/// millisecondes, le navigateur réessaie à chaque rendu de la grille et le
/// proxy relaie des centaines de requêtes mortes par seconde. Au bout de
/// [threshold] échecs consécutifs, l'hôte est tenu pour mort pendant [ttl]
/// et les requêtes suivantes sont servies localement, sans appel sortant.
pub struct AssetFailureCache {
threshold: u32,
ttl: Duration,
failures: Mutex<HashMap<String, u32>>,
down_until: Mutex<HashMap<String, Instant>>,
}
impl AssetFailureCache {
pub fn new(threshold: u32, ttl: Duration) -> Self {
Self {
threshold,
ttl,
failures: Mutex::default(),
down_until: Mutex::default(),
}
}
pub fn is_down(&self, host: &str) -> bool {
self.is_down_at(host, Instant::now())
}
fn is_down_at(&self, host: &str, now: Instant) -> bool {
let key = host.to_lowercase();
let mut down = self.down_until.lock().unwrap();
let Some(until) = down.get(&key).copied() else {
return false;
};
if now < until {
return true;
}
// Coupure expirée : ardoise vierge pour laisser une vraie chance.
down.remove(&key);
self.failures.lock().unwrap().remove(&key);
false
}
pub fn record_failure(&self, host: &str) {
self.record_failure_at(host, Instant::now());
}
fn record_failure_at(&self, host: &str, now: Instant) {
let key = host.to_lowercase();
let count = {
let mut failures = self.failures.lock().unwrap();
let count = failures.entry(key.clone()).or_insert(0);
*count += 1;
*count
};
if count >= self.threshold {
self.down_until.lock().unwrap().insert(key, now + self.ttl);
}
}
pub fn record_success(&self, host: &str) {
let key = host.to_lowercase();
self.failures.lock().unwrap().remove(&key);
self.down_until.lock().unwrap().remove(&key);
}
}
/// L'URL désigne-t-elle une image ou un logo ? Ces URL échappent à
/// l'allowlist de domaine (les revendeurs hébergent leurs picons ailleurs
/// que le panneau) et méritent un repli visuel plutôt qu'une erreur.
fn is_static_asset_url(url: &reqwest::Url) -> bool {
let path = url.path();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]
.iter()
.any(|ext| path.ends_with(ext))
|| path.contains("/picons/")
|| path.contains("/logos/")
}
/// Hôte à ne jamais atteindre par le proxy (loopback, LAN privé,
/// link-local/métadonnées cloud) — protection SSRF des URL d'images qui
/// échappent à l'allowlist de domaine.
pub fn is_forbidden_proxy_host(host: &str) -> bool {
let lower = host.to_lowercase();
let lower = lower.trim_start_matches('[').trim_end_matches(']');
if lower == "localhost" || lower == "::1" {
return true;
}
let Ok(ip) = lower.parse::<IpAddr>() else {
return false; // nom d'hôte : validé par l'allowlist de domaine
};
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| o[0] == 10
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|| (o[0] == 192 && o[1] == 168)
|| (o[0] == 169 && o[1] == 254)
|| o[0] == 0
}
IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xffc0) == 0xfe80,
}
}
fn host_of(url: &reqwest::Url) -> String {
url.host_str()
.unwrap_or("")
.trim_start_matches('[')
.trim_end_matches(']')
.to_string()
}
const ALLOWED_RESPONSE_HEADERS: [&str; 6] = [
"content-type",
"content-range",
"accept-ranges",
"cache-control",
"server",
"date",
];
const ALLOWED_REQUEST_HEADERS: [&str; 4] = ["user-agent", "accept", "range", "referer"];
pub struct ProxyHandler {
asset_failures: AssetFailureCache,
/// Playlist servant à l'allowlist, mise en cache 5 minutes pour éviter
/// une requête SQL par segment.
playlist_cache: Mutex<Option<(PlaylistConfig, Instant)>>,
}
enum ProxyError {
Response(Resp),
Failure(String),
}
impl ProxyHandler {
pub fn new() -> Self {
Self {
asset_failures: AssetFailureCache::new(3, Duration::from_secs(300)),
playlist_cache: Mutex::new(None),
}
}
fn cached_playlist(&self, state: &AppState, req: &Req) -> Option<PlaylistConfig> {
if let Some((playlist, expiry)) = self.playlist_cache.lock().unwrap().as_ref()
&& Instant::now() < *expiry
{
return Some(playlist.clone());
}
let playlist = state.get_playlist(req)?;
*self.playlist_cache.lock().unwrap() =
Some((playlist.clone(), Instant::now() + Duration::from_secs(300)));
Some(playlist)
}
pub async fn handle(&self, state: &AppState, req: &Req) -> Resp {
// Le test de chemin passe AVANT tout : les autres requêtes doivent
// retomber sur les étages suivants de la cascade.
let Some(api_path) = req.path.strip_prefix("api/xtream/") else {
return http::empty(404);
};
// Authentification par session (en-tête ou cookie HttpOnly posé au
// login : `img src` et hls.js n'envoient que le cookie). Un proxy
// ouvert offrait un rebond SSRF non authentifié.
let authorized = extract_token(req)
.is_some_and(|token| state.db.find_session_by_token(&token).is_some());
if !authorized {
return http::text(401, "Unauthorized");
}
let mut api_path = api_path.to_string();
if api_path.starts_with("http%3A") || api_path.starts_with("https%3A") {
api_path = percent_encoding::percent_decode_str(&api_path)
.decode_utf8_lossy()
.into_owned();
}
if !api_path.starts_with("http://") && !api_path.starts_with("https://") {
return http::text(
400,
"Invalid API URL. Expected format: /api/xtream/http://...",
);
}
let mut full_url = api_path;
if !req.query.is_empty() {
full_url.push(if full_url.contains('?') { '&' } else { '?' });
full_url.push_str(&req.query);
}
let target = reqwest::Url::parse(&full_url).ok();
match self.forward(state, req, target.clone()).await {
Ok(resp) => resp,
Err(ProxyError::Response(resp)) => resp,
Err(ProxyError::Failure(error)) => {
// Une erreur reqwest recopie l'URL amont, identifiants inclus :
// jamais de détail vers le client, log masqué.
println!(
"[ProxyHandler] error on {}: {}",
redact_url(&req.path),
redact_url(&error)
);
if let Some(target) = target.filter(is_static_asset_url) {
self.asset_failures.record_failure(&host_of(&target));
return missing_image_response();
}
http::json(500, &json!({"error": "Proxy error"}))
}
}
}
async fn forward(
&self,
state: &AppState,
req: &Req,
target: Option<reqwest::Url>,
) -> Result<Resp, ProxyError> {
let target =
target.ok_or_else(|| ProxyError::Failure("FormatException: invalid URL".into()))?;
if target.scheme() != "http" && target.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported URL scheme",
)));
}
let target_host = host_of(&target);
if is_forbidden_proxy_host(&target_host) {
println!("[Proxy] Blocked SSRF attempt to private host: {target_host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
// Images : tout hôte autorisé (CDN de picons des revendeurs).
let is_static_asset = is_static_asset_url(&target);
// Hôte d'images déjà constaté mort : réponse immédiate, sans appel
// sortant ni log — c'est précisément le flot qu'on veut éteindre.
if is_static_asset && self.asset_failures.is_down(&target_host) {
return Ok(missing_image_response());
}
let mut allowed_host = None;
if !is_static_asset {
// Appels d'API : allowlist de domaine.
let Some(playlist) = self.cached_playlist(state, req) else {
return Err(ProxyError::Response(http::text(
403,
"No active playlist configuration found to validate request",
)));
};
let allowed = reqwest::Url::parse(&playlist.dns)
.map(|u| host_of(&u).to_lowercase())
.unwrap_or_default();
if target_host.to_lowercase() != allowed {
println!(
"[Proxy] Blocked SSRF attempt to {} (Allowed: {allowed})",
target_host.to_lowercase()
);
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
allowed_host = Some(allowed);
}
let mut headers = reqwest::header::HeaderMap::new();
let mut set = |name: &str, value: &str| {
if let (Ok(n), Ok(v)) = (
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
reqwest::header::HeaderValue::from_str(value),
) {
headers.insert(n, v);
}
};
set("user-agent", "VLC/3.0.18 LibVLC/3.0.18");
set("accept", "*/*");
set("accept-encoding", "identity");
for name in ALLOWED_REQUEST_HEADERS {
if let Some(value) = req.header(name) {
set(name, value);
}
}
println!("[Proxy] Forwarding to: {}", redact_url(target.as_str()));
let method = reqwest::Method::from_bytes(req.method.as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
let post_body = (req.method == axum::http::Method::POST).then(|| req.body.clone());
// Redirections suivies À LA MAIN : chaque destination est revalidée
// (hôte privé, allowlist). Une 302 du serveur amont suffisait sinon à
// atteindre un hôte interne malgré l'anti-SSRF.
let mut current = target.clone();
let mut redirects = 0;
let response = loop {
let mut builder = state
.http_manual
.request(method.clone(), current.clone())
.headers(headers.clone());
if let Some(body) = &post_body {
builder = builder.body(body.clone());
}
// 90 s : laisse le front (60 s) abandonner proprement le premier.
let response = tokio::time::timeout(Duration::from_secs(90), builder.send())
.await
.map_err(|_| ProxyError::Failure("TimeoutException after 0:01:30.000000".into()))?
.map_err(|e| ProxyError::Failure(format!("{e:?}")))?;
let status = response.status().as_u16();
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let Some(location) = location.filter(|_| (300..400).contains(&status)) else {
break response;
};
redirects += 1;
if redirects > 3 {
return Err(ProxyError::Response(http::text(403, "Too many redirects")));
}
current = current
.join(&location)
.map_err(|e| ProxyError::Failure(e.to_string()))?;
if current.scheme() != "http" && current.scheme() != "https" {
return Err(ProxyError::Response(http::text(
403,
"Unsupported redirect scheme",
)));
}
let host = host_of(&current);
if is_forbidden_proxy_host(&host) {
println!("[Proxy] Blocked SSRF redirect to private host: {host}");
return Err(ProxyError::Response(http::text(
403,
"Access to this host is forbidden",
)));
}
if let Some(allowed) = &allowed_host
&& host.to_lowercase() != *allowed
{
println!("[Proxy] Blocked redirect to {host} (Allowed: {allowed})");
return Err(ProxyError::Response(http::text(
403,
"Access to this domain is forbidden by policy",
)));
}
};
// Une image en erreur n'a rien à transmettre au navigateur : relayer
// le 503 déclenchait un nouveau cycle de requêtes à chaque rendu.
// L'échec est compté sur l'hôte DEMANDÉ (clé du garde-fou ci-dessus).
let status = response.status().as_u16();
if is_static_asset {
if status >= 400 {
self.asset_failures.record_failure(&target_host);
return Ok(missing_image_response());
}
self.asset_failures.record_success(&target_host);
}
let upstream_headers = response.headers().clone();
let mut resp = http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
);
http::set_header(&mut resp, "access-control-allow-origin", "*");
for name in ALLOWED_RESPONSE_HEADERS {
let values: Vec<&str> = upstream_headers
.get_all(name)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
if !values.is_empty() {
http::set_header(&mut resp, name, &values.join(", "));
}
}
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bloque_le_loopback() {
assert!(is_forbidden_proxy_host("127.0.0.1"));
assert!(is_forbidden_proxy_host("localhost"));
assert!(is_forbidden_proxy_host("::1"));
assert!(is_forbidden_proxy_host("[::1]"));
}
#[test]
fn bloque_les_plages_privees() {
assert!(is_forbidden_proxy_host("10.0.0.5"));
assert!(is_forbidden_proxy_host("172.16.0.1"));
assert!(is_forbidden_proxy_host("172.31.255.255"));
assert!(is_forbidden_proxy_host("192.168.1.10"));
assert!(is_forbidden_proxy_host("169.254.169.254"));
assert!(is_forbidden_proxy_host("0.0.0.0"));
}
#[test]
fn autorise_les_ip_publiques_et_les_noms() {
assert!(!is_forbidden_proxy_host("8.8.8.8"));
assert!(!is_forbidden_proxy_host("172.32.0.1"));
assert!(!is_forbidden_proxy_host("cdn.example.com"));
}
#[test]
fn cache_des_hotes_morts() {
let cache = AssetFailureCache::new(3, Duration::from_secs(300));
let t0 = Instant::now();
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("cdn.example", t0);
cache.record_success("cdn.example");
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
cache.record_failure_at("CDN.example", t0);
cache.record_failure_at("cdn.example", t0);
assert!(cache.is_down_at("cdn.example", t0));
// Coupure expirée : ardoise vierge.
assert!(!cache.is_down_at("cdn.example", t0 + Duration::from_secs(301)));
cache.record_failure_at("cdn.example", t0);
assert!(!cache.is_down_at("cdn.example", t0));
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Adressage des sessions de lecture d'un enregistrement.
//!
//! Un enregistrement est transcodé à la volée, séquentiellement. Reprendre à
//! 45 min sans le dire à FFmpeg imposerait d'attendre que l'encodeur y
//! arrive : le lecteur demande donc une playlist qui *commence* à cette
//! position (`?start=2700`), servie par une session FFmpeg dédiée.
use regex::Regex;
use std::sync::LazyLock;
/// Position de départ demandée (`?start=<secondes>`), bornée au positif.
pub fn parse_recording_start(raw: Option<&str>) -> u64 {
let Some(value) = raw.and_then(|r| r.trim().parse::<f64>().ok()) else {
return 0;
};
if !value.is_finite() || value <= 0.0 {
return 0;
}
value.floor() as u64
}
/// Segment de chemin qui rattache un `.ts` à SA session : la query string ne
/// survit pas aux URL relatives de la playlist, et les playlists démarrées à
/// 0 s et à 45 min demanderaient sinon toutes deux `segment_000.ts`.
pub fn recording_offset_key(start: u64) -> String {
format!("t{start}")
}
/// Inverse de [recording_offset_key] ; `None` si la clé est mal formée.
pub fn parse_recording_offset_key(key: &str) -> Option<u64> {
let digits = key.strip_prefix('t')?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit() || b == b'+') {
return None;
}
digits.parse::<u64>().ok()
}
static SEGMENT_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^(segment_\d+\.ts)\s*$").unwrap());
/// Préfixe les lignes de segment de [playlist] par [offset_key] ; balises
/// `#EXT-X-…` et lignes vides passent telles quelles.
pub fn rewrite_recording_playlist(playlist: &str, offset_key: &str) -> String {
SEGMENT_LINE
.replace_all(playlist, |caps: &regex::Captures| {
format!("{offset_key}/{}", &caps[1])
})
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lit_la_position() {
assert_eq!(parse_recording_start(Some("2700")), 2700);
assert_eq!(parse_recording_start(Some("2700.9")), 2700);
for bad in [
None,
Some(""),
Some("abc"),
Some("-30"),
Some("NaN"),
Some("Infinity"),
] {
assert_eq!(parse_recording_start(bad), 0);
}
}
#[test]
fn cle_d_offset_aller_retour() {
for start in [0, 1, 2700, 86400] {
assert_eq!(
parse_recording_offset_key(&recording_offset_key(start)),
Some(start)
);
}
for bad in ["t", "segment_000.ts", "..", "t-30", "tabc"] {
assert_eq!(parse_recording_offset_key(bad), None, "{bad}");
}
}
const PLAYLIST: &str = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXTINF:4.000000,\nsegment_000.ts\n#EXTINF:4.000000,\nsegment_001.ts\n";
#[test]
fn rattache_chaque_segment_a_sa_session() {
let out = rewrite_recording_playlist(PLAYLIST, "t2700");
assert!(out.contains("t2700/segment_000.ts"));
assert!(out.contains("t2700/segment_001.ts"));
assert!(!out.contains("\nsegment_"));
}
#[test]
fn laisse_les_balises() {
let out = rewrite_recording_playlist(PLAYLIST, "t0");
assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:3\n"));
assert!(out.contains("#EXT-X-PLAYLIST-TYPE:EVENT"));
assert_eq!(out.matches("#EXTINF:4.000000,").count(), 2);
}
#[test]
fn idempotent() {
let once = rewrite_recording_playlist(PLAYLIST, "t60");
assert_eq!(rewrite_recording_playlist(&once, "t60"), once);
}
}
+379
View File
@@ -0,0 +1,379 @@
//! `/api/recordings` : liste, programmation, arrêt, suppression, logs.
//! `/api/season-passes` : enregistrements répétés par titre d'émission.
use crate::dart_time::DartDateTime;
use crate::http::{self, Req, Resp, json, segments};
use crate::models::{Recording, User};
use crate::services::recording_scheduler::{RecordingScheduler, recordings_dir_path};
use crate::state::SharedState;
use crate::util::{media_probe, safe_path};
use chrono::{DateTime, Utc};
use regex::Regex;
use serde_json::{Map, Value, json};
use std::path::Path;
use std::sync::LazyLock;
/// L'utilisateur peut-il agir sur cet enregistrement ?
fn can_access(user: Option<&User>, recording: &Recording) -> bool {
user.is_some_and(|u| u.is_admin || recording.user_id == u.id)
}
pub async fn recordings(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => list(state, req).await,
("POST", []) => create(state, req),
("DELETE", [id]) => delete(state, req, id),
("POST", ["stop", id]) => stop(state, req, id),
("GET" | "HEAD", ["logs", id]) => logs(state, req, id).await,
_ => http::not_found(),
}
}
async fn logs(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
let Some(file_path) = &recording.file_path else {
return json(
404,
&json!({"error": "Aucun fichier ni log associé pour le moment."}),
);
};
// Enregistrements en .mkv, log .log à côté.
let log_path = Path::new(file_path).with_extension("log");
// Anti traversée : le log doit rester dans le dossier des enregistrements.
let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), &log_path.to_string_lossy())
else {
return json(403, &json!({"error": "Chemin de log invalide"}));
};
match tokio::fs::read(&safe).await {
Ok(bytes) => json(200, &json!({"logs": String::from_utf8_lossy(&bytes)})),
Err(_) => json(404, &json!({"error": "Le fichier de log est introuvable."})),
}
}
/// Liste les enregistrements de l'utilisateur (tous pour un admin),
/// enrichis du suivi : taille, progression, relances, durée réelle.
async fn list(state: &SharedState, req: &Req) -> Resp {
let recordings = match &req.user {
Some(user) if !user.is_admin => state.db.get_user_recordings(&user.id),
_ => state.db.get_all_recordings(),
};
let now = Utc::now();
let mut enriched = Vec::with_capacity(recordings.len());
for r in &recordings {
let mut map = enrich(&state.scheduler, r, now);
// Durée réelle du média : `end_time - start_time` n'est que la durée
// programmée, fausse pour un enregistrement arrêté ou tronqué.
if let Some(duration) = probe_duration(r).await {
map.insert("duration_seconds".into(), json!(duration));
}
enriched.push(Value::Object(map));
}
json(200, &Value::Array(enriched))
}
/// Une capture en cours grossit en permanence : le cache de ffprobe serait
/// invalidé à chaque appel. Seuls les enregistrements terminés sont mesurés.
async fn probe_duration(r: &Recording) -> Option<f64> {
let path = r.file_path.as_deref()?;
if r.status != "completed" {
return None;
}
let safe = safe_path::resolve_within(&recordings_dir_path(), path)?;
media_probe::duration(&safe).await
}
fn enrich(scheduler: &RecordingScheduler, r: &Recording, now: DateTime<Utc>) -> Map<String, Value> {
let mut map = r.to_map();
if r.status == "recording" {
let start = r.start_utc();
let total = (r.end_utc() - start).num_seconds();
if total > 0 {
let elapsed = (now - start).num_seconds();
let pct = (elapsed as f64 * 100.0 / total as f64)
.clamp(0.0, 100.0)
.round() as i64;
map.insert("progress_pct".into(), json!(pct));
}
map.insert("is_active".into(), json!(scheduler.is_capturing(&r.id)));
if let Some(retries) = scheduler.retry_count_of(&r.id) {
map.insert("retry_count".into(), json!(retries));
}
}
if let Some(path) = &r.file_path
&& let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
{
map.insert("file_size_bytes".into(), json!(meta.len()));
}
map
}
static ZONE_SUFFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[+-]\d{2}:?\d{2}$").unwrap());
/// Date ISO-8601 AVEC fuseau, normalisée en UTC. Une date naïve est
/// ambiguë : l'interpréter dans le fuseau du serveur décalait
/// l'enregistrement de plusieurs heures selon le TZ du conteneur.
fn parse_zoned_date(raw: Option<&Value>) -> Option<DateTime<Utc>> {
let s = match raw? {
Value::String(s) => s.clone(),
Value::Null => return None,
other => other.to_string(),
};
if s.is_empty() || !(s.ends_with('Z') || ZONE_SUFFIX.is_match(&s)) {
return None;
}
DartDateTime::parse(&s).map(|d| d.to_utc())
}
fn to_text(v: Option<&Value>) -> Option<String> {
match v? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
fn create(state: &SharedState, req: &Req) -> Resp {
let Some(user_id) = req
.user
.as_ref()
.map(|u| u.id.clone())
.or(req.user_id.clone())
else {
return json(401, &json!({"error": "Authentification requise"}));
};
let Ok(Value::Object(data)) = serde_json::from_slice::<Value>(&req.body) else {
return json(400, &json!({"error": "Corps JSON invalide"}));
};
let channel_id = to_text(data.get("channel_id")).unwrap_or_default();
let stream_url = to_text(data.get("stream_url")).unwrap_or_default();
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let (Some(start), Some(end)) = (
parse_zoned_date(data.get("start_time")),
parse_zoned_date(data.get("end_time")),
) else {
return json(
400,
&json!({"error": "start_time et end_time doivent être des dates ISO-8601 avec fuseau (ex: 2026-08-27T21:00:00Z)"}),
);
};
if end <= start {
return json(
400,
&json!({"error": "end_time doit être après start_time"}),
);
}
let max_hours: i64 = std::env::var("MAX_RECORDING_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(12);
if end - start > chrono::Duration::hours(max_hours) {
return json(
400,
&json!({"error": format!("Durée maximale dépassée ({max_hours} h)")}),
);
}
let title = to_text(data.get("title")).unwrap_or_else(|| "Sans Titre".into());
match state
.db
.create_recording(&user_id, &channel_id, &stream_url, &title, start, end)
{
Ok(recording) => json(200, &Value::Object(recording.to_map())),
Err(e) => {
println!("[RecordingsApi] Erreur à la création: {e}");
json(500, &json!({"error": "Erreur lors de la programmation"}))
}
}
}
/// Annule ou supprime un enregistrement : FFmpeg arrêté s'il tourne,
/// fichiers (.mkv, .log, parties) supprimés avec la ligne.
fn delete(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
// Tuer FFmpeg AVANT de supprimer de la base.
state.scheduler.stop_recording(id);
if let Some(path) = &recording.file_path {
// Confiné au dossier des enregistrements.
if let Some(safe) = safe_path::resolve_within(&recordings_dir_path(), path) {
RecordingScheduler::delete_recording_files(&safe);
}
}
state.db.delete_recording(id);
json(
200,
&json!({"message": "Enregistrement supprimé avec succès"}),
)
}
fn stop(state: &SharedState, req: &Req, id: &str) -> Resp {
let Some(recording) = state.db.get_recording_by_id(id) else {
return json(404, &json!({"error": "Enregistrement non trouvé"}));
};
if !can_access(req.user.as_ref(), &recording) {
return json(403, &json!({"error": "Accès refusé"}));
}
if state.scheduler.stop_recording(id) {
return json(200, &json!({"message": "Enregistrement arrêté"}));
}
match recording.status.as_str() {
// Rien capturé : annulé, pas « terminé » (la lecture échouerait).
"scheduled" => {
state
.db
.update_recording_status(id, "cancelled", None, None, false);
json(200, &json!({"message": "Enregistrement annulé"}))
}
// « recording » sans processus (orphelin) : clôturer.
"recording" => {
state
.db
.update_recording_status(id, "completed", None, None, false);
json(
200,
&json!({"message": "Enregistrement marqué comme terminé"}),
)
}
// Déjà clôturé : ne pas écraser le statut final.
_ => json(200, &json!({"message": "Enregistrement déjà clôturé"})),
}
}
// ==================== /api/season-passes ====================
pub fn season_passes(state: &SharedState, req: &Req, rest: &str) -> Resp {
match (req.method.as_str(), segments(rest).as_slice()) {
("GET" | "HEAD", []) => {
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
let passes = if user.is_admin {
state.db.get_all_season_passes()
} else {
state.db.get_season_passes_for_user(&user.id)
};
json(
200,
&Value::Array(passes.into_iter().map(Value::Object).collect()),
)
}
("POST", []) => create_pass(state, req),
("DELETE", [id]) => {
let Some(pass) = state.db.get_season_pass_by_id(id) else {
return json(404, &json!({"error": "Season Pass non trouvé"}));
};
// Seul le propriétaire ou un admin supprime.
let owner = pass.get("user_id").and_then(Value::as_str);
let allowed = req
.user
.as_ref()
.is_some_and(|u| u.is_admin || owner == Some(u.id.as_str()));
if !allowed {
return json(403, &json!({"error": "Accès refusé"}));
}
state.db.delete_season_pass(id);
json(200, &json!({"message": "Season Pass supprimé"}))
}
_ => http::not_found(),
}
}
fn create_pass(state: &SharedState, req: &Req) -> Resp {
let data = match serde_json::from_slice::<Value>(&req.body) {
Ok(Value::Object(map)) => map,
Ok(_) => return json(500, &json!({"error": "Erreur: invalid body"})),
Err(e) => {
return json(
500,
&json!({"error": format!("Erreur: FormatException: {e}")}),
);
}
};
let field = |k: &str| {
data.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let (show_title, channel_id, stream_url) = (
field("show_title"),
field("channel_id"),
field("stream_url"),
);
if show_title.is_empty() {
return json(400, &json!({"error": "show_title est requis"}));
}
if channel_id.is_empty() {
return json(400, &json!({"error": "channel_id est requis"}));
}
if stream_url.is_empty() {
return json(400, &json!({"error": "stream_url est requis"}));
}
let Some(user) = &req.user else {
return json(401, &json!({"error": "Authentification requise"}));
};
// 'exact' par défaut : « Journal » ne doit pas capturer tous les
// programmes qui contiennent le mot.
let match_mode = if data.get("match_mode").and_then(Value::as_str) == Some("contains") {
"contains"
} else {
"exact"
};
let duplicate = state
.db
.get_season_passes_for_user(&user.id)
.iter()
.any(|p| {
p.get("show_title")
.and_then(Value::as_str)
.is_some_and(|t| t.to_lowercase() == show_title.to_lowercase())
&& p.get("channel_id").and_then(Value::as_str) == Some(channel_id.as_str())
});
if duplicate {
return json(
409,
&json!({"error": "Un Season Pass identique existe déjà"}),
);
}
match state
.db
.create_season_pass(&user.id, &show_title, &channel_id, &stream_url, match_mode)
{
Ok(pass) => {
println!("[SeasonPass] Créé: \"{show_title}\" sur chaîne {channel_id}");
json(201, &Value::Object(pass))
}
Err(e) => json(500, &json!({"error": format!("Erreur: {e}")})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exige_un_fuseau() {
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).is_some());
assert_eq!(
parse_zoned_date(Some(&json!("2026-08-27T23:00:00+02:00"))).unwrap(),
parse_zoned_date(Some(&json!("2026-08-27T21:00:00Z"))).unwrap()
);
assert!(parse_zoned_date(Some(&json!("2026-08-27T21:00:00"))).is_none());
assert!(parse_zoned_date(Some(&json!(""))).is_none());
assert!(parse_zoned_date(None).is_none());
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Fichiers statiques de la build web, avec politique de cache.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use axum::body::Body;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio_util::io::ReaderStream;
const NO_STORE: [(&str, &str); 3] = [
(
"cache-control",
"no-store, no-cache, must-revalidate, max-age=0",
),
("pragma", "no-cache"),
("expires", "0"),
];
/// Sert la build web en appliquant la politique de cache :
/// - points d'entrée versionnés (`?v=<empreinte>`) servis réécrits, jamais
/// mis en cache ;
/// - `.html`, `.js`, `.json` sans cache pour que les mises à jour soient vues
/// tout de suite, sauf les libs vendorisées (hls.js/mpegts.js, ~750 Ko,
/// versions figées) ;
/// - le reste en cache un jour.
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let path = req.path.as_str();
let key = if path.is_empty() { "index.html" } else { path };
if req.is_get()
&& let Some(content) = state.versioned.get(key)
{
let content_type = if path.ends_with(".js") {
"text/javascript; charset=utf-8"
} else {
"text/html; charset=utf-8"
};
let mut resp = http::with_headers(
http::response(200, content.clone()),
&[("content-type", content_type)],
);
for (name, value) in NO_STORE {
http::set_header(&mut resp, name, value);
}
return resp;
}
let resp = serve_file(&state.web_path, req).await;
let vendored = path.starts_with("vendor/");
if !vendored
&& (path.is_empty()
|| path.ends_with(".html")
|| path.ends_with(".js")
|| path.ends_with(".json"))
{
return http::with_headers(resp, &NO_STORE);
}
http::with_headers(resp, &[("cache-control", "public, max-age=86400")])
}
/// Chemin disque de la requête, ou `None` s'il sort de la racine.
fn resolve(root: &str, path: &str) -> Option<PathBuf> {
let mut resolved = PathBuf::from(root);
for segment in path.split('/').filter(|s| !s.is_empty()) {
let decoded = percent_encoding::percent_decode_str(segment)
.decode_utf8()
.ok()?;
if decoded == "." || decoded == ".." || decoded.contains(['/', '\\', ':', '\0']) {
return None;
}
resolved.push(decoded.as_ref());
}
Some(resolved)
}
fn http_date(time: SystemTime) -> String {
let dt: chrono::DateTime<chrono::Utc> = time.into();
dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
async fn serve_file(root: &str, req: &Req) -> Resp {
if !req.is_get() {
return http::text(405, "Method Not Allowed");
}
let Some(mut file_path) = resolve(root, &req.path) else {
return http::not_found();
};
if file_path.is_dir() {
// Répertoire sans `/` final : redirection, comme shelf_static.
if !req.path.is_empty() && !req.path.ends_with('/') {
let index = file_path.join("index.html");
if index.is_file() {
let mut resp = http::empty(301);
http::set_header(&mut resp, "location", &format!("/{}/", req.path));
return resp;
}
return http::not_found();
}
file_path = file_path.join("index.html");
}
let Ok(meta) = tokio::fs::metadata(&file_path).await else {
return http::not_found();
};
if !meta.is_file() {
return http::not_found();
}
let modified = meta.modified().ok();
if let (Some(modified), Some(since)) = (modified, req.header("if-modified-since"))
&& let Ok(since) = chrono::DateTime::parse_from_rfc2822(since)
{
let modified: chrono::DateTime<chrono::Utc> = modified.into();
if modified.timestamp() <= since.timestamp() {
return http::empty(304);
}
}
let content_type = content_type_of(&file_path);
let body = if req.method == axum::http::Method::HEAD {
Body::empty()
} else {
match tokio::fs::File::open(&file_path).await {
Ok(file) => Body::from_stream(ReaderStream::new(file)),
Err(_) => return http::not_found(),
}
};
let mut resp = http::response(200, body);
http::set_header(&mut resp, "content-length", &meta.len().to_string());
if let Some(ct) = content_type {
http::set_header(&mut resp, "content-type", &ct);
}
if let Some(modified) = modified {
http::set_header(&mut resp, "last-modified", &http_date(modified));
}
resp
}
fn content_type_of(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_lowercase();
Some(match ext.as_str() {
"js" | "mjs" => "text/javascript".to_string(),
"html" | "htm" => "text/html".to_string(),
"wasm" => "application/wasm".to_string(),
_ => mime_guess::from_ext(&ext).first_raw()?.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuse_la_traversee() {
assert!(resolve("/app/web", "../etc/passwd").is_none());
assert!(resolve("/app/web", "a/%2e%2e/b").is_none());
assert!(resolve("/app/web", "a/..%2Fb").is_none());
assert_eq!(
resolve("/app/web", "vendor/hls.min.js").unwrap(),
PathBuf::from("/app/web").join("vendor").join("hls.min.js")
);
}
}
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
//! Passerelle authentifiée vers `player_api.php` du panneau Xtream.
//!
//! Le front ne voit jamais les identifiants Xtream : il appelle
//! `GET /api/xtream-api?action=...` avec son jeton de session et le serveur
//! injecte username/password de la playlist de l'utilisateur.
use crate::http::{self, Req, Resp};
use crate::state::AppState;
use crate::util::log_redactor::redact_url;
use axum::body::Body;
use futures::TryStreamExt;
use serde_json::json;
use std::time::Duration;
/// Paramètres que le client peut transmettre à Xtream.
const ALLOWED_PARAMS: [&str; 7] = [
"action",
"category_id",
"stream_id",
"series_id",
"vod_id",
"limit",
"type",
];
pub async fn handle(state: &AppState, req: &Req) -> Resp {
let Some(playlist) = state.get_playlist(req) else {
return http::json(403, &json!({"error": "No playlist configured"}));
};
let Ok(mut target) = reqwest::Url::parse(&format!("{}/player_api.php", playlist.dns)) else {
return http::json(500, &json!({"error": "Upstream Xtream API error"}));
};
{
let mut pairs = target.query_pairs_mut();
pairs.clear();
pairs.append_pair("username", &playlist.username);
pairs.append_pair("password", &playlist.password);
// Ordre et dédoublonnage de la query cliente, comme `queryParameters`.
let mut seen = Vec::new();
for (key, _) in url::form_urlencoded::parse(req.query.as_bytes()) {
if ALLOWED_PARAMS.contains(&key.as_ref()) && !seen.contains(&key) {
seen.push(key);
}
}
let params = req.query_params();
for key in seen {
if let Some(value) = params.get(key.as_ref()) {
pairs.append_pair(&key, value);
}
}
}
let send = state
.http
.get(target.clone())
.header("User-Agent", "VLC/3.0.18 LibVLC/3.0.18")
.header("Accept", "*/*")
.send();
let response = match tokio::time::timeout(Duration::from_secs(90), send).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => return upstream_error(&target, &format!("{e:?}")),
Err(_) => return upstream_error(&target, "TimeoutException after 0:01:30.000000"),
};
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let status = response.status().as_u16();
http::with_headers(
http::response(
status,
Body::from_stream(response.bytes_stream().map_err(std::io::Error::other)),
),
&[
("content-type", &content_type),
("cache-control", "no-store"),
],
)
}
fn upstream_error(target: &reqwest::Url, error: &str) -> Resp {
println!(
"[XtreamApi] Error forwarding to {}: {}",
redact_url(target.as_str()),
redact_url(error)
);
http::json(500, &json!({"error": "Upstream Xtream API error"}))
}