Files
hottub/src/proxies/eporner.rs
Simon e5a2afcc70 eporner: stream through the server; the signed CDN links are IP-locked
The redirect proxy from 6ba65f1 still played the one-minute "watch on
eporner.com" clip away from home: checked from foreign networks
(check-host.net nodes in BR/DE/HU), a freshly signed link answers 302 ->
static.eporner.com/na.mp4, while the IP that resolved it gets the file. A
redirect therefore only works for a player behind the server's public IP.

/proxy/eporner/{alnum_id}/{height}.mp4 now streams the mp4: resolves the
link (cached 90 min, re-resolved when the CDN answers with a redirect),
forwards Range, and returns a sized body so 206 responses keep
Content-Length. It is no longer a redirect proxy, so app clients keep the
URLs in formats. Verified locally: HEAD, 0-1, mid-file and open-ended ranges
return 206 with Content-Length/Content-Range and valid MP4 data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
2026-10-22 20:46:14 +00:00

197 lines
6.7 KiB
Rust

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<HashMap<String, (Instant, String)>> {
static LINKS: OnceLock<Mutex<HashMap<String, (Instant, String)>>> = 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<Client> = 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<u32>)> {
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::<u32>().ok());
Some((alnum_id.to_string(), height))
}
async fn resolve_link(
requester: &web::types::State<Requester>,
alnum_id: &str,
height: Option<u32>,
) -> Option<String> {
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<Requester>,
alnum_id: &str,
height: Option<u32>,
fresh: bool,
) -> Option<String> {
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<Requester>,
) -> Result<impl web::Responder, web::Error> {
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::<u64>().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<dyn std::error::Error>));
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);
}
}