animeidhentai fixed

This commit is contained in:
Simon
2026-06-23 06:07:36 +00:00
parent 0a1bc6b727
commit 614361f0f3

View File

@@ -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}/`). // 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 // The real MP4 lives on a Cloudflare-fronted R2 bucket (`r2.1hanime.com`) behind a signed
// `?verify=<ts>-<sig>` token produced by an obfuscated browser challenge // `?verify=<ts>-<sig>` token produced by an obfuscated browser challenge
// (`player.php` -> `player-core-v2.php` -> `get-video-url-v2.php`): a proof-of-work over five // (`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. // The signed CDN URL itself additionally requires a *browser-grade* TLS fingerprint (JA3) to
// curl_cffi/AVFoundation/Safari pass; our Rust HTTP stack (wreq) does NOT — every emulation // clear Cloudflare's managed challenge — plain TLS (including every wreq emulation profile we
// profile is JA3-blocked here — so we cannot stream the bytes through the server. Instead this // tried) gets a CF interactive-challenge page, not the file. curl_cffi's `impersonate="chrome"`
// is a redirect proxy (same pattern as `jable`): HEAD returns 200 so probes/health-checks pass, // reliably clears it (verified end-to-end with yt-dlp). Earlier this proxy 302-redirected to the
// and GET 302-redirects to the freshly-resolved signed URL, which the Hot Tub client fetches // signed URL and left the final CF-passing fetch to the client's own TLS stack — fragile, since
// directly with its own (CF-accepted) TLS stack. yt-dlp resolves it with `--impersonate`. // 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 // 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). // downstream media detection keys off the extension; it is stripped before resolving).
use ntex::http::Method; 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 ntex::web::{self, HttpRequest};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use serde_json::Value;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant}; 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 base64::{Engine as _, engine::general_purpose::STANDARD};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC}; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
@@ -267,6 +277,139 @@ fn embed_id_from_endpoint(endpoint: &str) -> String {
.to_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<ChildStdout>, 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<String> {
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<web::HttpResponse> {
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, std::io::Error>(Bytes::from(buf)), (child, reader)))
}
Err(err) => Some((Err(err), (child, reader))),
}
},
));
Some(builder.streaming(stream))
}
pub async fn serve_media( pub async fn serve_media(
req: HttpRequest, req: HttpRequest,
requester: web::types::State<Requester>, requester: web::types::State<Requester>,
@@ -277,8 +420,7 @@ pub async fn serve_media(
return Ok(web::HttpResponse::BadRequest().finish()); return Ok(web::HttpResponse::BadRequest().finish());
} }
// HEAD: answer probes/health-checks directly. We can't fetch the CF-guarded CDN from this // HEAD: answer probes/health-checks directly without paying for a full challenge-resolve.
// TLS stack, but the resource is a direct MP4, so advertise it as one without touching it.
if req.method() == Method::HEAD { if req.method() == Method::HEAD {
return Ok(web::HttpResponse::Ok() return Ok(web::HttpResponse::Ok()
.header(CONTENT_TYPE, "video/mp4") .header(CONTENT_TYPE, "video/mp4")
@@ -286,12 +428,31 @@ pub async fn serve_media(
.finish()); .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(); let mut requester = requester.get_ref().clone();
match resolve_cached(&mut requester, &embed_id, false).await { let Some(signed_url) = resolve_cached(&mut requester, &embed_id, false).await else {
Some(signed_url) => Ok(web::HttpResponse::Found() return Ok(web::HttpResponse::BadGateway().finish());
.header(LOCATION, signed_url) };
.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()), None => Ok(web::HttpResponse::BadGateway().finish()),
} }
} }