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
This commit is contained in:
@@ -1,65 +1,196 @@
|
||||
use ntex::web;
|
||||
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;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpornerProxy {}
|
||||
/// 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;
|
||||
|
||||
impl EpornerProxy {
|
||||
pub fn new() -> Self {
|
||||
EpornerProxy {}
|
||||
/// `{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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `{alnum_id}[/{height}]`.
|
||||
fn parse_endpoint(endpoint: &str) -> Option<(String, Option<u32>)> {
|
||||
let mut parts = endpoint.trim_matches('/').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))
|
||||
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)
|
||||
}
|
||||
|
||||
/// Resolves a fresh signed mp4 at play time. The CDN URLs expire two hours
|
||||
/// after the `/xhr/video` call, and an expired one is answered with a 302
|
||||
/// to `static.eporner.com/na.mp4`, a one-minute "watch it on eporner.com"
|
||||
/// clip; so a stale listing, favourite or history entry plays that clip.
|
||||
pub async fn get_video_url(
|
||||
&self,
|
||||
endpoint: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> String {
|
||||
let Some((alnum_id, height)) = Self::parse_endpoint(&endpoint) else {
|
||||
return String::new();
|
||||
/// 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 page_url = format!("https://www.eporner.com/video-{alnum_id}/");
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let Some(ranked) = resolve_ranked(&mut requester, &alnum_id, &page_url).await else {
|
||||
return String::new();
|
||||
let mut request = if head {
|
||||
cdn_client().head(&url)
|
||||
} else {
|
||||
cdn_client().get(&url)
|
||||
};
|
||||
height
|
||||
.and_then(|h| ranked.iter().find(|(height, _)| *height == h))
|
||||
.or_else(|| ranked.first())
|
||||
.map(|(_, format)| format.url.clone())
|
||||
.unwrap_or_default()
|
||||
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::EpornerProxy;
|
||||
use super::parse_endpoint;
|
||||
|
||||
#[test]
|
||||
fn parses_id_and_optional_height() {
|
||||
assert_eq!(
|
||||
EpornerProxy::parse_endpoint("exfeTXHuL1x/720"),
|
||||
parse_endpoint("exfeTXHuL1x/720.mp4"),
|
||||
Some(("exfeTXHuL1x".to_string(), Some(720)))
|
||||
);
|
||||
assert_eq!(
|
||||
EpornerProxy::parse_endpoint("/exfeTXHuL1x/"),
|
||||
parse_endpoint("/exfeTXHuL1x/"),
|
||||
Some(("exfeTXHuL1x".to_string(), None))
|
||||
);
|
||||
assert_eq!(EpornerProxy::parse_endpoint("../etc"), None);
|
||||
assert_eq!(parse_endpoint("../etc"), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user