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
197 lines
6.7 KiB
Rust
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);
|
|
}
|
|
}
|