eporner: play through a redirect proxy; the signed CDN links expire after 2h

Expired eporner mp4 links are answered with a 302 to static.eporner.com/
na.mp4, a one-minute "watch it on eporner.com" clip. The links expire
exactly two hours after the /xhr/video call, so anything played from a
favourite, history entry or a list left open past that hit the decoy.

New /proxy/eporner/{alnum_id}/{height} re-resolves the hash/xhr chain at
play time and 302s to the matching (else best) quality. The eporner
provider and melonstube's eporner destinations now emit these proxy URLs;
verified app clients get the best quality as video.url via the existing
redirect-proxy rule. Verified live: proxy -> fresh CDN link -> 206 video/mp4
for app and non-app clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
Simon
2026-10-22 18:09:23 +00:00
parent b131910ea2
commit 6ba65f1457
7 changed files with 154 additions and 20 deletions

65
src/proxies/eporner.rs Normal file
View File

@@ -0,0 +1,65 @@
use ntex::web;
use crate::util::eporner_resolve::resolve_ranked;
use crate::util::requester::Requester;
#[derive(Debug, Clone)]
pub struct EpornerProxy {}
impl EpornerProxy {
pub fn new() -> Self {
EpornerProxy {}
}
/// `{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))
}
/// 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();
};
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();
};
height
.and_then(|h| ranked.iter().find(|(height, _)| *height == h))
.or_else(|| ranked.first())
.map(|(_, format)| format.url.clone())
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::EpornerProxy;
#[test]
fn parses_id_and_optional_height() {
assert_eq!(
EpornerProxy::parse_endpoint("exfeTXHuL1x/720"),
Some(("exfeTXHuL1x".to_string(), Some(720)))
);
assert_eq!(
EpornerProxy::parse_endpoint("/exfeTXHuL1x/"),
Some(("exfeTXHuL1x".to_string(), None))
);
assert_eq!(EpornerProxy::parse_endpoint("../etc"), None);
}
}