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}/`).
|
// 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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user