mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-19 13:08:05 +02:00
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:
1 parent
f82c70ae05
commit
3ea6fa0ae3
109 files changed
+14008
-11047
No files matched your search
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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: ®ex::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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"}))
|
||||
}
|
||||
Reference in new issue
Block a user