From 614361f0f38721ac4888202b87f233a85e62859f Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 23 Jun 2026 06:07:36 +0000 Subject: [PATCH] animeidhentai fixed --- src/proxies/animeidhentai.rs | 193 ++++++++++++++++++++++++++++++++--- 1 file changed, 177 insertions(+), 16 deletions(-) diff --git a/src/proxies/animeidhentai.rs b/src/proxies/animeidhentai.rs index d06ae90..9bdc587 100644 --- a/src/proxies/animeidhentai.rs +++ b/src/proxies/animeidhentai.rs @@ -1,30 +1,40 @@ -// Redirect proxy for animeidhentai.com. +// Media proxy for animeidhentai.com. // // animeidhentai embeds every episode through nhplayer.com (`https://nhplayer.com/v/{embedId}/`). // The real MP4 lives on a Cloudflare-fronted R2 bucket (`r2.1hanime.com`) behind a signed // `?verify=-` token produced by an obfuscated browser challenge // (`player.php` -> `player-core-v2.php` -> `get-video-url-v2.php`): a proof-of-work over five -// DOM-embedded parts plus a fixed-but-valid fingerprint, which we replicate server-side. +// DOM-embedded parts plus a fixed-but-valid fingerprint, which we replicate server-side. None of +// that resolution chain runs against the CF-protected host, so it goes through the normal +// `Requester` (wreq) fine. // -// The signed URL additionally requires a *browser* TLS fingerprint (JA3) to clear Cloudflare. -// curl_cffi/AVFoundation/Safari pass; our Rust HTTP stack (wreq) does NOT — every emulation -// profile is JA3-blocked here — so we cannot stream the bytes through the server. Instead this -// is a redirect proxy (same pattern as `jable`): HEAD returns 200 so probes/health-checks pass, -// and GET 302-redirects to the freshly-resolved signed URL, which the Hot Tub client fetches -// directly with its own (CF-accepted) TLS stack. yt-dlp resolves it with `--impersonate`. +// The signed CDN URL itself additionally requires a *browser-grade* TLS fingerprint (JA3) to +// clear Cloudflare's managed challenge — plain TLS (including every wreq emulation profile we +// tried) gets a CF interactive-challenge page, not the file. curl_cffi's `impersonate="chrome"` +// reliably clears it (verified end-to-end with yt-dlp). Earlier this proxy 302-redirected to the +// signed URL and left the final CF-passing fetch to the client's own TLS stack — fragile, since +// it assumed the real client's TLS fingerprint would also clear Cloudflare. Instead we now fetch +// the bytes server-side through a `curl_cffi` subprocess (same technique already used for other +// CF-protected hosts in this codebase, e.g. `porndish`/`supjav`) and stream them straight through +// to the client over a plain connection, so no client-side TLS fingerprint is ever exposed to CF. // // Proxy URL shape: `/proxy/animeidhentai/{embedId}.mp4` (the trailing `.mp4` is cosmetic so // downstream media detection keys off the extension; it is stripped before resolving). use ntex::http::Method; -use ntex::http::header::{ACCEPT_RANGES, CONTENT_TYPE, LOCATION}; +use ntex::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE}; +use ntex::util::Bytes; use ntex::web::{self, HttpRequest}; use once_cell::sync::Lazy; use regex::Regex; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::HashMap; +use std::process::Stdio; use std::sync::Mutex; use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; +use tokio::process::{Child, ChildStdout, Command}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC}; @@ -267,6 +277,139 @@ fn embed_id_from_endpoint(endpoint: &str) -> String { .to_string() } +// Runs inside a `python3 -c` subprocess. Performs the actual CF-passing fetch via curl_cffi's +// chrome impersonation, writes one JSON metadata line (status + a handful of response headers) +// to stdout followed by a newline, then streams the raw response body straight after it on the +// same stdout pipe. The metadata line is written (and flushed) before any body bytes, so the Rust +// side can split the stream cleanly with a single `read_line`. +const CURL_CFFI_STREAM_SCRIPT: &str = r#" +import sys, json +from curl_cffi import requests + +def main(): + url = sys.argv[1] + headers = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {} + try: + r = requests.get( + url, + impersonate="chrome", + headers=headers, + stream=True, + timeout=45, + allow_redirects=True, + ) + except Exception: + sys.stdout.write(json.dumps({"status": 0, "headers": {}}) + "\n") + sys.stdout.flush() + return + + meta = {"status": r.status_code, "headers": {k: v for k, v in r.headers.items()}} + sys.stdout.write(json.dumps(meta) + "\n") + sys.stdout.flush() + + if 200 <= r.status_code < 300: + try: + for chunk in r.iter_content(chunk_size=65536): + if chunk: + sys.stdout.buffer.write(chunk) + except Exception: + pass + sys.stdout.flush() + +main() +"#; + +/// Spawn the curl_cffi streaming subprocess, read its metadata line, and hand back the parsed +/// status/headers plus a reader positioned at the start of the body (any failure is `None`, so +/// the caller can force a fresh challenge-resolve and retry once). +async fn spawn_curl_cffi_stream( + url: &str, + range: Option<&str>, +) -> Option<(u16, Value, BufReader, Child)> { + let mut headers = serde_json::Map::new(); + if let Some(range) = range { + headers.insert("Range".to_string(), Value::String(range.to_string())); + } + let headers_json = Value::Object(headers).to_string(); + + let mut child = Command::new("python3") + .arg("-c") + .arg(CURL_CFFI_STREAM_SCRIPT) + .arg(url) + .arg(&headers_json) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .ok()?; + + let stdout = child.stdout.take()?; + let mut reader = BufReader::new(stdout); + let mut meta_line = String::new(); + reader.read_line(&mut meta_line).await.ok()?; + let meta: Value = serde_json::from_str(meta_line.trim()).ok()?; + let status = meta.get("status").and_then(Value::as_u64).unwrap_or(0) as u16; + Some((status, meta, reader, child)) +} + +fn copy_header(meta: &Value, key: &str) -> Option { + meta.get("headers")? + .as_object()? + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .and_then(|(_, v)| v.as_str()) + .map(str::to_string) +} + +/// Fetch `url` through curl_cffi (passing Cloudflare's challenge) and stream the body straight +/// into an ntex response, forwarding the upstream status (200/206) and media headers. `None` on +/// any failure (spawn error, non-2xx upstream, or malformed metadata) so the caller can retry. +async fn stream_cdn(url: &str, range: Option<&str>) -> Option { + let (status, meta, reader, child) = spawn_curl_cffi_stream(url, range).await?; + if !(200..300).contains(&status) { + return None; + } + + let status_code = ntex::http::StatusCode::from_u16(status).unwrap_or(ntex::http::StatusCode::OK); + let mut builder = web::HttpResponse::build(status_code); + if let Some(v) = copy_header(&meta, "content-type") { + builder.header(CONTENT_TYPE, v); + } else { + builder.header(CONTENT_TYPE, "video/mp4"); + } + if let Some(v) = copy_header(&meta, "content-length") { + builder.header(CONTENT_LENGTH, v); + } + if let Some(v) = copy_header(&meta, "content-range") { + builder.header(CONTENT_RANGE, v); + } + builder.header( + ACCEPT_RANGES, + copy_header(&meta, "accept-ranges").unwrap_or_else(|| "bytes".to_string()), + ); + + let stream = Box::pin(futures::stream::unfold( + (child, reader), + |(mut child, mut reader)| async move { + let mut buf = vec![0u8; 65_536]; + match reader.read(&mut buf).await { + Ok(0) => { + let _ = child.wait().await; + None + } + Ok(n) => { + buf.truncate(n); + Some((Ok::(Bytes::from(buf)), (child, reader))) + } + Err(err) => Some((Err(err), (child, reader))), + } + }, + )); + + Some(builder.streaming(stream)) +} + pub async fn serve_media( req: HttpRequest, requester: web::types::State, @@ -277,8 +420,7 @@ pub async fn serve_media( return Ok(web::HttpResponse::BadRequest().finish()); } - // HEAD: answer probes/health-checks directly. We can't fetch the CF-guarded CDN from this - // TLS stack, but the resource is a direct MP4, so advertise it as one without touching it. + // HEAD: answer probes/health-checks directly without paying for a full challenge-resolve. if req.method() == Method::HEAD { return Ok(web::HttpResponse::Ok() .header(CONTENT_TYPE, "video/mp4") @@ -286,12 +428,31 @@ pub async fn serve_media( .finish()); } - // GET/POST: resolve the signed CDN URL and 302 to it so the client fetches it directly. + // GET/POST: resolve the signed CDN URL, then fetch+stream the bytes through curl_cffi so the + // CF-passing TLS handshake happens on the server, not the client. + let range = req + .headers() + .get(RANGE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let mut requester = requester.get_ref().clone(); - match resolve_cached(&mut requester, &embed_id, false).await { - Some(signed_url) => Ok(web::HttpResponse::Found() - .header(LOCATION, signed_url) - .finish()), + let Some(signed_url) = resolve_cached(&mut requester, &embed_id, false).await else { + return Ok(web::HttpResponse::BadGateway().finish()); + }; + + if let Some(resp) = stream_cdn(&signed_url, range.as_deref()).await { + return Ok(resp); + } + + // The cached signed URL may have expired (its verify token is short-lived) — force a fresh + // challenge-resolve and retry once before giving up. + let Some(fresh_url) = resolve_cached(&mut requester, &embed_id, true).await else { + return Ok(web::HttpResponse::BadGateway().finish()); + }; + + match stream_cdn(&fresh_url, range.as_deref()).await { + Some(resp) => Ok(resp), None => Ok(web::HttpResponse::BadGateway().finish()), } }