From e5a2afcc70b250ccb86617d4cebb3a10575bedcf Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 22 Oct 2026 20:46:14 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi --- docs/provider-catalog.md | 4 +- src/providers/eporner.rs | 5 +- src/providers/melonstube.rs | 4 +- src/proxies/eporner.rs | 209 +++++++++++++++++++++++++++++------- src/proxies/mod.rs | 3 - src/proxy.rs | 13 +-- src/util/eporner_resolve.rs | 22 ++-- 7 files changed, 194 insertions(+), 66 deletions(-) diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index c0ed55c..95977be 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -83,7 +83,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us | `fullporner` | `mainstream-tube` | no | no | HTML scraper for fullporner.com; thumbnail IDs derived from `/thumb/{id}.jpg` URLs and used to build direct `xiaoshenke.net/vid/{id}/720` media redirect URLs (Referer + User-Agent headers required); supports cat:/category:/pornstar:/star: shortcut queries; no proxy needed. | | `cumlouder` | `mainstream-tube` | no | no | HTML scraper for cumlouder.com (Spanish/amateur tube, same platform family as superporn). Cards are `a.muestra-escena[href^="/porn-video/"]` with lazy thumb in `img[data-src]` (protocol-relative `//th0cdn.cumlouder.com/...`, direct, no Referer/proxy), `h2` title, `.vistas` views, `.minutos` duration (`06:14 m`); relative date only ("2 years ago"), so `uploadedAt` is not set. Default feed `/porn/` (page N≥2 is the bare `/N/` path, not `/porn/N/`); category `/porn-videos/{slug}/{N}/`; tag `/tag/{slug}/{N}/`; series `/series/{slug}/{N}/`. **The site's own `/search/` (POST `form[search]` or `?q=`) is behind a Cloudflare managed challenge — 403 even in a real browser — so keyword queries are routed instead: a query matching a hardcoded category (72, scraped from `/categories/`) goes to the category archive, anything else to `/tag/{slug}/` (unknown tags 404 → empty list).** Pornstar pages live at `/girl/{slug}/{N}/` (same cards/pagination; unknown slug = fast 404). A free-form keyword that is not a category is tried as a pornstar page first, then as a tag archive; `girl:`/`pornstar:`/`star:`/`model:` force the pornstar page. Supports `cat:`/`category:`, `tag:`, `series:` prefixes and the `categories` option. A Cloudflare challenge on the raw listing fetch is handed to the requester's FlareSolverr chain. `video.url` is the detail page, which yt-dlp resolves natively via the generic html5 extractor (the page embeds a signed `m5cdnst.cumlouder.com/*.mp4?secure=…` ``, no Referer needed), so no `formats`/enrichment. The origin intermittently answers a first, uncached request with a Cloudflare 522 (a retry succeeds) — the listing fetch retries up to 3×, and `check.py`/yt-dlp may show a one-off `HTTP=-1`/522 on a cold detail page that is not a provider fault. No proxy needed. | | `thepornbunny` | `mainstream-tube` | no | yes | KVS-style HTML scraper for thepornbunny.com; 24 items per site page; thumbnails at `https://www.thepornbunny.com/images/thumb/{id}.webp` from `data-original` attribute (no proxy needed); studio exposed as uploader; pornstar names in tags; `/proxy/thepornbunny/{slug}` fetches the video page, extracts `generate_mp4(enc_data, key, rnd, video_id)` args, decrypts `enc_data` via PBKDF2-HMAC-SHA512+AES-256-CBC to get an OK.ru session key, calls `api.ok.ru/fb.do?method=video.get&session_key=KEY&vids=RND` to get signed CDN URLs, and returns 302 to the best-quality okcdn.ru/vkuser.net MP4 URL (no special client headers needed); supports sort: new/popular/rated, 20 hardcoded categories via `categories` option, and tag:/category:/studio:/pornstar: query shortcuts. | -| `eporner` | `mainstream-tube` | no | yes | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); supports sort: new/popular/rated/best; 65 hardcoded categories, plus `cat:`/`category:`, `tag:`, `pornstar:`/`star:`, `uploader:`/`profile:` query-prefix routing to the matching archive, a bare-keyword category-name match, and a background-loaded pornstar name→URL map from `/pornstar-list/` (falls back to `/tag/{slug}` keyword search otherwise). `video.url` is the detail page — **not** yt-dlp-resolvable and not directly playable on its own: opening it bare serves a small restricted/preview clip. Real playback needs per-card enrichment (bounded `buffer_unordered(6)`): fetch the detail page for its 32-hex `hash`, derive the site's own hashed token (`calc_hash` — split into four 8-hex chunks, each parsed as u32 and base36-encoded, concatenated; reverse-engineered from the player JS), then call the site's own `GET /xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false` JSON API for direct per-resolution mp4 URLs into `formats[]`. Separately from the hash dance, the resolved CDN mp4 URLs enforce their own Referer check: a request with no Referer or one pointing at eporner.com gets the real file, but any other non-empty Referer gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the exact "this video is only available at eporner.com"-style short clip a client sees if its default (or app-injected) Referer doesn't match, so every format explicitly declares `http_headers: {Referer: "https://www.eporner.com/"}` rather than relying on the client's default. No proxy needed (formats are direct CDN URLs, just header-gated). **2026-09-21:** the `calc_hash`/hash-regex/`alnum_id_from_href`/`resolve_formats` logic moved to `src/util/eporner_resolve.rs` so `melonstube` (a meta-search aggregator that redirects out to eporner.com for ~34% of its own cards) can reuse the exact same resolution without a cross-provider `use`, which would break either provider's `HOT_TUB_PROVIDER=` single-provider build (only the one selected provider module gets compiled; `src/util/*` always does). `eporner.rs` itself now just calls `crate::util::eporner_resolve::resolve_formats(...)`. **2026-10-22:** the signed CDN URLs expire exactly two hours after the `/xhr/video` call (the leading number in `/{expiry}_{ip}_{n}/`; the embedded IP is not enforced) and an expired link gets the same `na.mp4` decoy, so favourites, history and long-open lists played the one-minute clip. Formats now point at the redirect proxy `/proxy/eporner/{alnum_id}/{height}`, which re-resolves at play time and 302s to the matching (else best) quality; verified app clients get the best one as `video.url` per the redirect-proxy rule. melonstube's eporner destinations use the same `resolve_proxied_formats`. | +| `eporner` | `mainstream-tube` | no | yes | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); supports sort: new/popular/rated/best; 65 hardcoded categories, plus `cat:`/`category:`, `tag:`, `pornstar:`/`star:`, `uploader:`/`profile:` query-prefix routing to the matching archive, a bare-keyword category-name match, and a background-loaded pornstar name→URL map from `/pornstar-list/` (falls back to `/tag/{slug}` keyword search otherwise). `video.url` is the detail page — **not** yt-dlp-resolvable and not directly playable on its own: opening it bare serves a small restricted/preview clip. Real playback needs per-card enrichment (bounded `buffer_unordered(6)`): fetch the detail page for its 32-hex `hash`, derive the site's own hashed token (`calc_hash` — split into four 8-hex chunks, each parsed as u32 and base36-encoded, concatenated; reverse-engineered from the player JS), then call the site's own `GET /xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false` JSON API for direct per-resolution mp4 URLs into `formats[]`. Separately from the hash dance, the resolved CDN mp4 URLs enforce their own Referer check: a request with no Referer or one pointing at eporner.com gets the real file, but any other non-empty Referer gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the exact "this video is only available at eporner.com"-style short clip a client sees if its default (or app-injected) Referer doesn't match, so every format explicitly declares `http_headers: {Referer: "https://www.eporner.com/"}` rather than relying on the client's default. No proxy needed (formats are direct CDN URLs, just header-gated). **2026-09-21:** the `calc_hash`/hash-regex/`alnum_id_from_href`/`resolve_formats` logic moved to `src/util/eporner_resolve.rs` so `melonstube` (a meta-search aggregator that redirects out to eporner.com for ~34% of its own cards) can reuse the exact same resolution without a cross-provider `use`, which would break either provider's `HOT_TUB_PROVIDER=` single-provider build (only the one selected provider module gets compiled; `src/util/*` always does). `eporner.rs` itself now just calls `crate::util::eporner_resolve::resolve_formats(...)`. **2026-10-22:** the signed CDN URLs expire exactly two hours after the `/xhr/video` call (the leading number in `/{expiry}_{ip}_{n}/`) and an expired link gets the same `na.mp4` decoy, so favourites, history and long-open lists played the one-minute clip. The links are also **IP-locked**: checked from foreign networks (check-host.net nodes) a fresh link answers 302 → `na.mp4`, while the resolving IP gets the file, so a redirect only works for a player behind the server's own public IP (a Jina fetch had misleadingly suggested otherwise). Formats therefore point at the streaming proxy `/proxy/eporner/{alnum_id}/{height}.mp4`: it resolves (cached 90 min per id/height, re-resolved when the CDN answers with a redirect), forwards `Range`, and returns a sized body so `206` responses keep `Content-Length` for AVPlayer. Not a redirect proxy, so app clients keep these in `formats`. All eporner playback bandwidth now flows through the server. melonstube's eporner destinations use the same `resolve_proxied_formats`. | | `xnxx` | `mainstream-tube` | no | no | HTML scraper for xnxx.com (10M+ videos); unified card parser handles two formats: `div.thumb-block[data-eid]` (search) and `div.thumb-block.video[data-video='{"id":...}']` (hits); eid extracted from `/video-{eid}/{slug}` URL path; thumbnails at `thumb-cdn77.xnxx-cdn.com` and `thumbs-gcore.xnxx-cdn.com` (no proxy, no Referer needed); 0-indexed pagination (page 1 = `/hits`, page N = `/hits/{N-1}`); default feed is `/hits` (most-viewed — xnxx has no chronological listing); search via `/search/{slug}` (works for keywords and tags); supports `tag:`, `cat:`, `category:` query shortcuts; yt-dlp resolves `video.url` natively (XNXX extractor, returns 4-7 HLS formats); no proxy needed. | | `xgroovy` | `mainstream-tube` | no | no | KVS (Kernel Video Sharing)-style HTML scraper for xgroovy.com; card selector `div.item[data-video-id]` with `data-views`/`data-rating` as clean numeric attributes directly on the card (no text-parsing needed, unlike xhamster); title/url from `a.popito[href] > strong.title`; thumbnails at `i.xgroovy.com` (no proxy, no Referer needed) from `img.thumb[data-jpg|src]`, preview clip from `img[data-preview]`; duration rendered as free text ("N min"/"N sec", not colon-separated) via a bespoke `parse_duration` helper; path-segment pagination (`/new/` = page 1, `/new/2/` = page 2, unlike xhamster's `?page=N` query string); feeds: `/new/` (default), categories via `/categories/{slug}/` (73 hardcoded categories as `categories` option), pornstars via `/pornstars/{slug}/`, channels via `/channels/{slug}/`; search via `/search/{dashed-query}/` (multi-word queries are lowercased and dash-joined); uploader kind inferred from URL path (`/pornstars/` vs `/channels/`) via `div.author-link > a`; supports `cat:`/`category:` query shortcuts plus static category name matching; yt-dlp resolves `video.url` via the generic html5-embed extractor (no dedicated xgroovy extractor, but resolves cleanly to a 1080p format); no proxy needed. | | `xhamster` | `mainstream-tube` | no | no | HTML scraper for xhamster.com; card selector `div[data-video-type="video"]` with `data-video-id`; thumbnails via `img[data-role="thumb-preview-img"]` at `ic-vt-nss.xhcdn.com` (no proxy, no Referer needed); pagination via `?page=N` query param (browse feeds use infinite-scroll so only search reliably returns different content per page); feeds: `/newest` (default), `/most-viewed`, `/best`; categories via `/categories/{slug}`; channels via `/channels/{slug}`; 43 hardcoded categories as `categories` option; uploader type inferred from URL path (`/channels/` → channel, `/creators/` → creator, `/pornstars/` → pornstar); supports `cat:`/`category:` and `channel:` query shortcuts, plus static category name matching; preview mp4 clips from `data-previewvideo` attribute; yt-dlp resolves `video.url` natively (xHamster extractor, 28 formats); no proxy needed. | @@ -116,7 +116,6 @@ These resolve a provider-specific input into a `302 Location`. - `/proxy/allpornstream/{endpoint}*` - `/proxy/tube8/{endpoint}*` - `/proxy/supjav/{endpoint}*` -- `/proxy/eporner/{alnum_id}/{height}` (fresh signed mp4 per play; the CDN links expire after 2h) - `/proxy/jable/{slug}*` - `/proxy/thepornbunny/{slug}*` - `/proxy/animeidhentai/{embedId}.mp4` (HEAD→200, GET→302 to the signed `r2.1hanime.com` CDN URL) @@ -127,6 +126,7 @@ These resolve a provider-specific input into a `302 Location`. These return binary media or images, sometimes rewriting manifests or forwarding cookies/referers. - `/proxy/shooshtime-media/{endpoint}*` +- `/proxy/eporner/{alnum_id}/{height}.mp4` (streams the IP-locked signed mp4 with Range passthrough; link cached 90 min) - `/proxy/noodlemagazine/{endpoint}*` - `/proxy/noodlemagazine-thumb/{endpoint}*` - `/proxy/hanime-cdn/{endpoint}*` diff --git a/src/providers/eporner.rs b/src/providers/eporner.rs index 26cb519..62add64 100644 --- a/src/providers/eporner.rs +++ b/src/providers/eporner.rs @@ -450,8 +450,9 @@ impl EpornerProvider { // redirect out to eporner.com, e.g. `melonstube`) rather than here, // since single-provider builds can't depend on another provider's // module — see that module's doc comment. - // Formats point at the eporner redirect proxy: the CDN links expire two - // hours after resolving and then play a "watch on eporner.com" clip. + // Formats point at the eporner streaming proxy: the CDN links expire + // after two hours and only serve the server's IP; anything else plays + // a one-minute "watch on eporner.com" clip. let formats = crate::util::eporner_resolve::resolve_proxied_formats( requester, options, diff --git a/src/providers/melonstube.rs b/src/providers/melonstube.rs index 9051af0..b42bddf 100644 --- a/src/providers/melonstube.rs +++ b/src/providers/melonstube.rs @@ -494,8 +494,8 @@ impl MelonstubeProvider { Self::parse_kvs_formats(&html, &origin) } - /// Through the eporner redirect proxy: its CDN links expire two hours - /// after resolving and then play a "watch on eporner.com" clip. + /// Through the eporner streaming proxy: its CDN links expire after two + /// hours and only serve the server's IP (else a "watch on eporner.com" clip). async fn resolve_eporner( requester: &mut Requester, options: &ServerOptions, diff --git a/src/proxies/eporner.rs b/src/proxies/eporner.rs index 5233ef9..8b05119 100644 --- a/src/proxies/eporner.rs +++ b/src/proxies/eporner.rs @@ -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> { + 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()); + } + } + } } - - /// `{alnum_id}[/{height}]`. - fn parse_endpoint(endpoint: &str) -> Option<(String, Option)> { - 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::().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, - ) -> 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, +) -> 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 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::().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::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); } } diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index 4f2b2f8..cb68559 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -1,7 +1,6 @@ use crate::proxies::archivebate::ArchivebateProxy; use crate::proxies::clapdat::ClapdatProxy; use crate::proxies::doodstream::DoodstreamProxy; -use crate::proxies::eporner::EpornerProxy; use crate::proxies::heavyfetish::HeavyfetishProxy; use crate::proxies::hqporner::HqpornerProxy; use crate::proxies::pornhd3x::Pornhd3xProxy; @@ -73,7 +72,6 @@ pub enum AnyProxy { ThaipornTv(ThaipornTvProxy), Tube8(Tube8Proxy), Supjav(SupjavProxy), - Eporner(EpornerProxy), } pub trait Proxy { @@ -100,7 +98,6 @@ impl Proxy for AnyProxy { AnyProxy::ThaipornTv(p) => p.get_video_url(url, requester).await, AnyProxy::Tube8(p) => p.get_video_url(url, requester).await, AnyProxy::Supjav(p) => p.get_video_url(url, requester).await, - AnyProxy::Eporner(p) => p.get_video_url(url, requester).await, } } } diff --git a/src/proxy.rs b/src/proxy.rs index 0ba439c..79065e9 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -3,7 +3,6 @@ use ntex::web::{self, HttpRequest}; use crate::proxies::archivebate::ArchivebateProxy; use crate::proxies::clapdat::ClapdatProxy; use crate::proxies::doodstream::DoodstreamProxy; -use crate::proxies::eporner::EpornerProxy; use crate::proxies::heavyfetish::HeavyfetishProxy; use crate::proxies::hqporner::HqpornerProxy; use crate::proxies::javtiful::JavtifulProxy; @@ -44,7 +43,6 @@ pub const REDIRECT_PROXIES: &[&str] = &[ "thaiporntv", "tube8", "supjav", - "eporner", ]; /// True for a `/proxy//...` URL (absolute or host-relative). @@ -117,11 +115,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(proxy2redirect)) .route(web::get().to(proxy2redirect)), ) - .service( - web::resource("/eporner/{endpoint}*") - .route(web::post().to(proxy2redirect)) - .route(web::get().to(proxy2redirect)), - ) .service( web::resource("/vidara/{endpoint}*") .route(web::post().to(proxy2redirect)) @@ -235,6 +228,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(crate::proxies::melonstube::serve)) .route(web::get().to(crate::proxies::melonstube::serve)), ); + cfg.service( + web::resource("/eporner/{endpoint}*") + .route(web::get().to(crate::proxies::eporner::serve_media)) + .route(web::head().to(crate::proxies::eporner::serve_media)), + ); cfg.service( web::resource("/xiaoshenke/{endpoint}*") .route(web::post().to(crate::proxies::xiaoshenke::serve_media)) @@ -277,7 +275,6 @@ fn get_proxy(proxy: &str) -> Option { "thaiporntv" => Some(AnyProxy::ThaipornTv(ThaipornTvProxy::new())), "tube8" => Some(AnyProxy::Tube8(Tube8Proxy::new())), "supjav" => Some(AnyProxy::Supjav(SupjavProxy::new())), - "eporner" => Some(AnyProxy::Eporner(EpornerProxy::new())), _ => None, } } diff --git a/src/util/eporner_resolve.rs b/src/util/eporner_resolve.rs index 456833a..751bd86 100644 --- a/src/util/eporner_resolve.rs +++ b/src/util/eporner_resolve.rs @@ -103,10 +103,11 @@ fn calc_hash(hash: &str) -> Option { /// lack thereof) can't accidentally trip this check. /// /// The signed CDN URLs also expire two hours after this call (the leading -/// number in `/{expiry}_{ip}_{n}/` is that deadline; the IP is not enforced) -/// and are then redirected to the same `na.mp4` decoy. Anything handed to a -/// client should therefore be a [`resolve_proxied_formats`] URL, which -/// resolves afresh at play time. Returns `(height, format)`, best first. +/// number in `/{expiry}_{ip}_{n}/` is that deadline) and only serve the IP +/// embedded there; anything else gets the same `na.mp4` decoy. +/// Anything handed to a client should therefore be a +/// [`resolve_proxied_formats`] URL, streamed through this server. +/// Returns `(height, format)`, best first. pub async fn resolve_ranked( requester: &mut Requester, alnum_id: &str, @@ -161,8 +162,8 @@ pub async fn resolve_ranked( } /// Resolves the qualities a video offers, but points each one at the -/// `/proxy/eporner/{alnum_id}/{height}` redirect proxy instead of the -/// two-hour CDN URL, so playback never starts from an expired link. +/// `/proxy/eporner/{alnum_id}/{height}.mp4` streaming proxy: the CDN URLs +/// expire after two hours and only serve the IP that resolved them. pub async fn resolve_proxied_formats( requester: &mut Requester, options: &ServerOptions, @@ -185,7 +186,7 @@ fn proxied_formats( ranked .into_iter() .map(|(height, mut format)| { - format.url = format!("{base}/proxy/eporner/{alnum_id}/{height}"); + format.url = format!("{base}/proxy/eporner/{alnum_id}/{height}.mp4"); format }) .collect() @@ -196,14 +197,15 @@ mod tests { use super::*; #[test] - fn proxied_formats_point_at_the_redirect_proxy() { + fn proxied_formats_point_at_the_streaming_proxy() { let ranked = vec![( 720, VideoFormat::new("https://vid.cdn/x-720p.mp4".into(), "720p".into(), "mp4".into()), )]; let formats = proxied_formats("https://hottub.example/", "exfeTXHuL1x", ranked); - assert_eq!(formats[0].url, "https://hottub.example/proxy/eporner/exfeTXHuL1x/720"); - assert!(crate::proxy::is_redirect_proxy_url(&formats[0].url)); + assert_eq!(formats[0].url, "https://hottub.example/proxy/eporner/exfeTXHuL1x/720.mp4"); + // Streamed, not redirected: app clients must keep it in `formats`. + assert!(!crate::proxy::is_redirect_proxy_url(&formats[0].url)); } #[test]