use std::collections::HashMap; use std::rc::Rc; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use futures::StreamExt; use http_body_util::BodyExt; use ntex::http::Method; use ntex::http::body::{Body, SizedStream}; use ntex::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE}; use ntex::util::Bytes; use ntex::web::{self, HttpRequest}; use wreq::Client; use wreq::redirect::Policy; use wreq_util::Emulation; use crate::util::eporner_resolve::resolve_ranked; use crate::util::requester::Requester; /// The signed CDN links expire two hours after resolving; renew well before. const LINK_TTL: Duration = Duration::from_secs(90 * 60); const LINK_CACHE_SIZE: usize = 2000; /// `{alnum_id}/{height}` -> (resolved at, signed CDN URL). fn links() -> &'static Mutex> { static LINKS: OnceLock>> = OnceLock::new(); LINKS.get_or_init(|| Mutex::new(HashMap::new())) } /// Redirects are not followed so the CDN's `na.mp4` decoy shows up as a 302. fn cdn_client() -> &'static Client { static CLIENT: OnceLock = OnceLock::new(); CLIENT.get_or_init(|| { Client::builder() .tls_cert_verification(false) .emulation(Emulation::Firefox151) .redirect(Policy::none()) .build() .expect("eporner CDN client") }) } /// `{alnum_id}[/{height}][.mp4]`. fn parse_endpoint(endpoint: &str) -> Option<(String, Option)> { let endpoint = endpoint.trim_matches('/').trim_end_matches(".mp4"); let mut parts = endpoint.split('/'); let alnum_id = parts .next() .filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()))?; let height = parts.next().and_then(|h| h.parse::().ok()); Some((alnum_id.to_string(), height)) } async fn resolve_link( requester: &web::types::State, alnum_id: &str, height: Option, ) -> Option { let page_url = format!("https://www.eporner.com/video-{alnum_id}/"); let mut requester = requester.get_ref().clone(); let ranked = resolve_ranked(&mut requester, alnum_id, &page_url).await?; height .and_then(|h| ranked.iter().find(|(height, _)| *height == h)) .or_else(|| ranked.first()) .map(|(_, format)| format.url.clone()) } async fn cached_link( requester: &web::types::State, alnum_id: &str, height: Option, fresh: bool, ) -> Option { let key = format!("{alnum_id}/{}", height.unwrap_or(0)); if !fresh { if let Ok(links) = links().lock() { if let Some((time, url)) = links.get(&key) { if time.elapsed() < LINK_TTL { return Some(url.clone()); } } } } let url = resolve_link(requester, alnum_id, height).await?; if let Ok(mut links) = links().lock() { if links.len() >= LINK_CACHE_SIZE { links.retain(|_, (time, _)| time.elapsed() < LINK_TTL); if links.len() >= LINK_CACHE_SIZE { links.clear(); } } links.insert(key, (Instant::now(), url.clone())); } Some(url) } /// Streams the mp4 through this server. The signed CDN links only serve the /// IP that resolved them (checked from other networks: they answer every /// other client with a 302 to `static.eporner.com/na.mp4`, a one-minute /// "watch it on eporner.com" clip), so a redirect to them only ever works for /// a player that shares the server's public IP. pub async fn serve_media( req: HttpRequest, requester: web::types::State, ) -> Result { let endpoint = req.match_info().query("endpoint").to_string(); let Some((alnum_id, height)) = parse_endpoint(&endpoint) else { return Ok(web::HttpResponse::BadRequest().finish()); }; let range = req .headers() .get(RANGE) .and_then(|value| value.to_str().ok()) .map(str::to_string); let head = req.method() == Method::HEAD; let mut upstream = None; for fresh in [false, true] { let Some(url) = cached_link(&requester, &alnum_id, height, fresh).await else { break; }; let mut request = if head { cdn_client().head(&url) } else { cdn_client().get(&url) }; request = request.header("Referer", "https://www.eporner.com/"); if let Some(range) = &range { request = request.header("Range", range); } match request.send().await { // A redirect is the decoy for an expired or foreign link. Ok(response) if !response.status().is_redirection() => { upstream = Some(response); break; } _ => continue, } } let Some(upstream) = upstream else { return Ok(web::HttpResponse::BadGateway().finish()); }; let status = upstream.status(); let headers = upstream.headers().clone(); let mut builder = web::HttpResponse::build(status); builder.header( CONTENT_TYPE, headers .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("video/mp4"), ); builder.header(ACCEPT_RANGES, "bytes"); if let Some(value) = headers.get(CONTENT_RANGE.as_str()).and_then(|v| v.to_str().ok()) { builder.header(CONTENT_RANGE, value); } if head { // `finish()` would send `Content-Length: 0` over the upstream length. return Ok(builder.body(Body::None)); } let length = headers .get(CONTENT_LENGTH.as_str()) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()); let stream = Box::pin(upstream.into_data_stream().map(|chunk| { chunk .map(|bytes| Bytes::copy_from_slice(bytes.as_ref())) .map_err(|e| std::io::Error::other(e.to_string())) })); let Some(length) = length else { return Ok(builder.streaming(stream)); }; // A sized body keeps `Content-Length` on the (206) responses; a chunked // one is what AVPlayer refuses to seek in. let stream = stream.map(|chunk| chunk.map_err(|e| Rc::new(e) as Rc)); Ok(builder.body(Body::from_message(SizedStream::new(length, stream)))) } #[cfg(test)] mod tests { use super::parse_endpoint; #[test] fn parses_id_and_optional_height() { assert_eq!( parse_endpoint("exfeTXHuL1x/720.mp4"), Some(("exfeTXHuL1x".to_string(), Some(720))) ); assert_eq!( parse_endpoint("/exfeTXHuL1x/"), Some(("exfeTXHuL1x".to_string(), None)) ); assert_eq!(parse_endpoint("../etc"), None); } }