animeidhentai fixed
This commit is contained in:
@@ -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=<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
|
||||
// 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<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(
|
||||
req: HttpRequest,
|
||||
requester: web::types::State<Requester>,
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user