From cf0ae15f93167dd567a8fa5f848e21a9ab437898 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 1 Nov 2026 07:48:35 +0000 Subject: [PATCH] hanime: play through a redirect proxy; the signed stream links expire Items now carry /proxy/hanime/{slug}/{height}.m3u8, which does a fresh handshake per play and 302s to the current playlist. The handshake moved to util/hanime_resolve.rs so single-provider builds keep the route. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi --- src/providers/hanime.rs | 221 +++++-------------------------------- src/proxies/hanime.rs | 71 ++++++++++++ src/proxies/mod.rs | 1 + src/proxy.rs | 5 + src/util/hanime_resolve.rs | 208 ++++++++++++++++++++++++++++++++++ src/util/mod.rs | 1 + 6 files changed, 312 insertions(+), 195 deletions(-) create mode 100644 src/proxies/hanime.rs create mode 100644 src/util/hanime_resolve.rs diff --git a/src/providers/hanime.rs b/src/providers/hanime.rs index f807f4d..8ce5806 100644 --- a/src/providers/hanime.rs +++ b/src/providers/hanime.rs @@ -1,13 +1,8 @@ -use aes_gcm::aead::{Aead, KeyInit, Payload}; -use aes_gcm::{Aes256Gcm, Key, Nonce}; use async_trait::async_trait; -use base64::Engine; use error_chain::error_chain; use futures::future::join_all; -use serde_json::json; -use sha2::{Digest, Sha256}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::SystemTime; use std::vec; use crate::DbPool; @@ -16,6 +11,7 @@ use crate::db; use crate::providers::{Provider, report_provider_error, report_provider_error_background}; use crate::status::*; use crate::util::cache::VideoCache; +use crate::util::hanime_resolve::{self as resolve, HanimeSource, SITE_ORIGIN}; use crate::videos::{self, ServerOptions, VideoItem}; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = @@ -34,14 +30,6 @@ error_chain! { /// Full catalogue dump. Ignores every query parameter, so filtering, sorting and /// pagination all happen client side. ~860 KB compressed, CDN cached for an hour. const INDEX_URL: &str = "https://guest.freeanimehentai.net/api/v11/search_hvs"; -const HANDSHAKE_URL: &str = "https://auth.hanime.tv/api/v11/handshake"; -const SITE_ORIGIN: &str = "https://hanime.tv"; -/// Envelope encryption used for the handshake request/response bodies. -const HANDSHAKE_KEY_SEED: &[u8] = b"htv-insecure-handshake-v1"; -const HANDSHAKE_AAD: &[u8] = b"htv-insecure-v1"; -/// Pieces of the `x-signature` pre-image, as baked into the site's wasm module. -const SIGNATURE_SECRET: &str = "Xkdi29"; -const SIGNATURE_SALT: &str = "mn2"; const INDEX_TTL_SECS: u64 = 3600; type IndexCache = OnceLock>)>>>; @@ -76,99 +64,6 @@ struct HanimeSearchResult { released_at_unix: u64, } -#[derive(serde::Deserialize, Debug)] -struct HanimeHandshakePayload { - #[serde(default)] - sources: Vec, -} - -#[derive(serde::Deserialize, Debug, Clone)] -struct HanimeSource { - #[serde(default)] - src: String, - #[serde(default)] - height: u32, - #[serde(default)] - label: String, - /// `"normal"` for the free streams, `"promotion"` for the premium-only teaser - /// entries which always carry an empty `src`. - #[serde(default)] - kind: String, -} - -fn b64() -> base64::engine::general_purpose::GeneralPurpose { - base64::engine::general_purpose::URL_SAFE_NO_PAD -} - -fn b64_decode(value: &str) -> Result> { - b64() - .decode(value.trim_end_matches('=')) - .map_err(|e| Error::from(format!("base64url decode failed: {e}"))) -} - -fn handshake_cipher() -> Aes256Gcm { - let digest = Sha256::digest(HANDSHAKE_KEY_SEED); - let key = Key::::from_slice(digest.as_slice()); - Aes256Gcm::new(key) -} - -/// `{"v":1,"alg":"AES-256-GCM","iv":..,"tag":..,"data":..}`, base64url encoded. -fn seal_envelope(plaintext: &[u8]) -> Result { - let mut iv = [0u8; 12]; - rand::fill(&mut iv); - let sealed = handshake_cipher() - .encrypt( - Nonce::from_slice(&iv), - Payload { - msg: plaintext, - aad: HANDSHAKE_AAD, - }, - ) - .map_err(|e| Error::from(format!("handshake encrypt failed: {e}")))?; - if sealed.len() < 16 { - return Err(Error::from("handshake ciphertext too short")); - } - let (data, tag) = sealed.split_at(sealed.len() - 16); - let envelope = json!({ - "v": 1, - "alg": "AES-256-GCM", - "iv": b64().encode(iv), - "tag": b64().encode(tag), - "data": b64().encode(data), - }); - Ok(b64().encode(envelope.to_string().as_bytes())) -} - -fn open_envelope(token: &str) -> Result> { - let envelope: serde_json::Value = serde_json::from_slice(&b64_decode(token)?) - .map_err(|e| Error::from(format!("handshake envelope is not JSON: {e}")))?; - let field = |name: &str| -> Result> { - let raw = envelope - .get(name) - .and_then(|v| v.as_str()) - .ok_or_else(|| Error::from(format!("handshake envelope missing {name}")))?; - b64_decode(raw) - }; - let iv = field("iv")?; - let mut ciphertext = field("data")?; - ciphertext.extend_from_slice(&field("tag")?); - handshake_cipher() - .decrypt( - Nonce::from_slice(&iv), - Payload { - msg: &ciphertext, - aad: HANDSHAKE_AAD, - }, - ) - .map_err(|e| Error::from(format!("handshake decrypt failed: {e}"))) -} - -fn signature_for(timestamp: u64) -> String { - let pre_image = - format!("{timestamp},{SIGNATURE_SECRET},{SITE_ORIGIN},{SIGNATURE_SALT},{timestamp}"); - hex::encode(Sha256::digest(pre_image.as_bytes())) -} - #[derive(Debug, Clone)] pub struct HanimeProvider; @@ -242,6 +137,25 @@ impl HanimeProvider { } } + /// Fresh free sources for a slug, best quality first. + async fn fetch_sources(&self, slug: &str, options: &ServerOptions) -> Result> { + let mut requester = + crate::providers::requester_or_default(options, module_path!(), "missing_requester"); + resolve::fetch_sources(&mut requester, slug) + .await + .map_err(Error::from) + } + + /// The stream URLs expire, so players get a stable redirect that does a fresh + /// handshake on every play instead of the signed link itself. + fn proxied_url(options: &ServerOptions, slug: &str, source: &HanimeSource) -> String { + crate::providers::build_proxy_url( + options, + "hanime", + &format!("{slug}/{}.m3u8", source.height), + ) + } + fn db_key(slug: &str) -> String { format!("https://hanime.tv/videos/hentai/{slug}") } @@ -319,86 +233,6 @@ impl HanimeProvider { Ok(items) } - /// Returns the free `sources` for a slug, best quality first. The auth host - /// occasionally refuses one of a burst of parallel connections, so retry once. - async fn fetch_sources(&self, slug: &str, options: &ServerOptions) -> Result> { - match self.handshake(slug, options).await { - Ok(sources) => Ok(sources), - Err(_) => { - ntex::time::sleep(ntex::time::Millis(500)).await; - self.handshake(slug, options).await - } - } - } - - async fn handshake(&self, slug: &str, options: &ServerOptions) -> Result> { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let token = seal_envelope( - json!({ - "timestamp_unix": timestamp, - "directive": "htv_player_handshake", - "slug": slug, - }) - .to_string() - .as_bytes(), - )?; - - let mut requester = - crate::providers::requester_or_default(options, module_path!(), "missing_requester"); - let response = requester - .post_json( - HANDSHAKE_URL, - &json!({ "token": token }), - vec![ - ("Origin".to_string(), SITE_ORIGIN.to_string()), - ("Referer".to_string(), format!("{SITE_ORIGIN}/")), - ("Accept".to_string(), "application/json".to_string()), - ("x-signature-version".to_string(), "web2".to_string()), - ("x-signature".to_string(), signature_for(timestamp)), - ("x-time".to_string(), timestamp.to_string()), - ], - ) - .await - .map_err(|e| Error::from(format!("handshake request failed: {e}")))?; - - let status = response.status(); - if !status.is_success() { - return Err(Error::from(format!("handshake returned HTTP {status}"))); - } - - // The interesting part of the answer travels in a response header, not the body. - let encrypted = response - .headers() - .get("x-token") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()) - .ok_or_else(|| Error::from("handshake response is missing x-token"))?; - - let payload: HanimeHandshakePayload = serde_json::from_slice(&open_envelope(&encrypted)?) - .map_err(|e| Error::from(format!("Failed to parse handshake payload: {e}")))?; - - let mut sources: Vec = payload - .sources - .into_iter() - .filter(|s| s.kind == "normal" && !s.src.is_empty()) - .collect(); - sources.sort_by(|a, b| b.height.cmp(&a.height)); - match sources.is_empty() { - true => Err(Error::from("handshake returned no playable sources")), - false => Ok(sources), - } - } - - fn source_url(source: &HanimeSource) -> String { - match source.src.starts_with("http") { - true => source.src.clone(), - false => format!("{SITE_ORIGIN}{}", source.src), - } - } - fn quality_label(source: &HanimeSource) -> String { match source.label.trim_end_matches('p') { "" => source.height.to_string(), @@ -452,15 +286,15 @@ impl HanimeProvider { .iter() .map(|source| { videos::VideoFormat::new( - Self::source_url(source), + Self::proxied_url(&options, &hit.slug, source), Self::quality_label(source), "m3u8".to_string(), ) }) .collect(); - let stream_url = Self::source_url(&sources[0]); + let stream_url = Self::proxied_url(&options, &hit.slug, &sources[0]); let duration = self - .fetch_duration(&stream_url, &options) + .fetch_duration(&resolve::source_url(&sources[0]), &options) .await .unwrap_or_default(); if let Ok(mut conn) = pool.get() { @@ -490,11 +324,8 @@ impl HanimeProvider { .and_then(|mut conn| db::get_video(&mut conn, db_key.clone()).ok().flatten()); match db_result { - Some(video_url) if video_url.contains("m3u8") || video_url.contains("/hls/") => { - let duration = self - .fetch_duration(&video_url, &options) - .await - .unwrap_or_default(); + Some(video_url) if video_url.contains("/proxy/hanime/") => { + let duration = 0; let formats = vec![videos::VideoFormat::new( video_url.clone(), "720".to_string(), diff --git a/src/proxies/hanime.rs b/src/proxies/hanime.rs new file mode 100644 index 0000000..7bfe949 --- /dev/null +++ b/src/proxies/hanime.rs @@ -0,0 +1,71 @@ +use ntex::http::Method; +use ntex::http::header::LOCATION; +use ntex::web::{self, HttpRequest}; + +use crate::util::hanime_resolve::{fetch_sources, pick_source, source_url}; +use crate::util::requester::Requester; + +/// `{slug}[/{height}][.m3u8]`. +fn parse_endpoint(endpoint: &str) -> Option<(String, Option)> { + let endpoint = endpoint.trim_matches('/').trim_end_matches(".m3u8"); + let mut parts = endpoint.split('/'); + let slug = parts + .next() + .filter(|slug| { + !slug.is_empty() + && slug + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + })?; + let height = parts.next().and_then(|h| h.parse::().ok()); + Some((slug.to_string(), height)) +} + +/// hanime's stream links expire, so the channel hands out this stable URL and a +/// fresh handshake happens on every play: redirects to the current playlist. +pub async fn redirect_to_media( + req: HttpRequest, + requester: web::types::State, +) -> Result { + let endpoint = req.match_info().query("endpoint").to_string(); + let Some((slug, height)) = parse_endpoint(&endpoint) else { + return Ok(web::HttpResponse::BadRequest().finish()); + }; + if req.method() == Method::HEAD { + return Ok(web::HttpResponse::Ok().finish()); + } + + let mut requester = requester.get_ref().clone(); + let Ok(sources) = fetch_sources(&mut requester, &slug).await else { + return Ok(web::HttpResponse::BadGateway().finish()); + }; + let Some(source) = pick_source(&sources, height) else { + return Ok(web::HttpResponse::NotFound().finish()); + }; + Ok(web::HttpResponse::Found() + .header(LOCATION, source_url(source)) + .finish()) +} + +#[cfg(test)] +mod tests { + use super::parse_endpoint; + + #[test] + fn parses_slug_and_height() { + assert_eq!( + parse_endpoint("natsu-zuma-2/720.m3u8"), + Some(("natsu-zuma-2".to_string(), Some(720))) + ); + assert_eq!( + parse_endpoint("/natsu-zuma-2"), + Some(("natsu-zuma-2".to_string(), None)) + ); + } + + #[test] + fn rejects_unsafe_slugs() { + assert_eq!(parse_endpoint("../etc/720.m3u8"), None); + assert_eq!(parse_endpoint(""), None); + } +} diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index cb68559..616758c 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -26,6 +26,7 @@ pub mod clapdat; pub mod doodstream; pub mod eporner; pub mod fikfapthumb; +pub mod hanime; pub mod hanimecdn; pub mod hanimethumb; pub mod heavyfetish; diff --git a/src/proxy.rs b/src/proxy.rs index 79065e9..47b2bee 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -157,6 +157,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(crate::proxies::fikfapthumb::get_image)) .route(web::get().to(crate::proxies::fikfapthumb::get_image)), ) + .service( + web::resource("/hanime/{endpoint}*") + .route(web::get().to(crate::proxies::hanime::redirect_to_media)) + .route(web::head().to(crate::proxies::hanime::redirect_to_media)), + ) .service( web::resource("/hanime-cdn/{endpoint}*") .route(web::post().to(crate::proxies::hanimecdn::get_image)) diff --git a/src/util/hanime_resolve.rs b/src/util/hanime_resolve.rs new file mode 100644 index 0000000..0068198 --- /dev/null +++ b/src/util/hanime_resolve.rs @@ -0,0 +1,208 @@ +//! hanime.tv stream resolution, shared by the `hanime` channel and its +//! `/proxy/hanime/` redirect proxy. +//! +//! Lives here (always compiled) rather than in `providers/hanime.rs` because +//! `HOT_TUB_PROVIDER=` single-provider builds only compile the selected +//! provider, while the proxy routes are always registered. + +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::Engine; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::util::requester::Requester; + +const HANDSHAKE_URL: &str = "https://auth.hanime.tv/api/v11/handshake"; +pub const SITE_ORIGIN: &str = "https://hanime.tv"; +/// Envelope encryption used for the handshake request/response bodies. +const HANDSHAKE_KEY_SEED: &[u8] = b"htv-insecure-handshake-v1"; +const HANDSHAKE_AAD: &[u8] = b"htv-insecure-v1"; +/// Pieces of the `x-signature` pre-image, as baked into the site's wasm module. +const SIGNATURE_SECRET: &str = "Xkdi29"; +const SIGNATURE_SALT: &str = "mn2"; + +type Result = std::result::Result; + +fn err(message: impl Into) -> String { + message.into() +} + +#[derive(serde::Deserialize, Debug)] +pub struct HanimeHandshakePayload { + #[serde(default)] + pub sources: Vec, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct HanimeSource { + #[serde(default)] + pub src: String, + #[serde(default)] + pub height: u32, + #[serde(default)] + pub label: String, + /// `"normal"` for the free streams, `"promotion"` for the premium-only teaser + /// entries which always carry an empty `src`. + #[serde(default)] + pub kind: String, +} + +fn b64() -> base64::engine::general_purpose::GeneralPurpose { + base64::engine::general_purpose::URL_SAFE_NO_PAD +} + +fn b64_decode(value: &str) -> Result> { + b64() + .decode(value.trim_end_matches('=')) + .map_err(|e| err(format!("base64url decode failed: {e}"))) +} + +fn handshake_cipher() -> Aes256Gcm { + let digest = Sha256::digest(HANDSHAKE_KEY_SEED); + let key = Key::::from_slice(digest.as_slice()); + Aes256Gcm::new(key) +} + +/// `{"v":1,"alg":"AES-256-GCM","iv":..,"tag":..,"data":..}`, base64url encoded. +fn seal_envelope(plaintext: &[u8]) -> Result { + let mut iv = [0u8; 12]; + rand::fill(&mut iv); + let sealed = handshake_cipher() + .encrypt( + Nonce::from_slice(&iv), + Payload { + msg: plaintext, + aad: HANDSHAKE_AAD, + }, + ) + .map_err(|e| err(format!("handshake encrypt failed: {e}")))?; + if sealed.len() < 16 { + return Err(err("handshake ciphertext too short")); + } + let (data, tag) = sealed.split_at(sealed.len() - 16); + let envelope = json!({ + "v": 1, + "alg": "AES-256-GCM", + "iv": b64().encode(iv), + "tag": b64().encode(tag), + "data": b64().encode(data), + }); + Ok(b64().encode(envelope.to_string().as_bytes())) +} + +fn open_envelope(token: &str) -> Result> { + let envelope: serde_json::Value = serde_json::from_slice(&b64_decode(token)?) + .map_err(|e| err(format!("handshake envelope is not JSON: {e}")))?; + let field = |name: &str| -> Result> { + let raw = envelope + .get(name) + .and_then(|v| v.as_str()) + .ok_or_else(|| err(format!("handshake envelope missing {name}")))?; + b64_decode(raw) + }; + let iv = field("iv")?; + let mut ciphertext = field("data")?; + ciphertext.extend_from_slice(&field("tag")?); + handshake_cipher() + .decrypt( + Nonce::from_slice(&iv), + Payload { + msg: &ciphertext, + aad: HANDSHAKE_AAD, + }, + ) + .map_err(|e| err(format!("handshake decrypt failed: {e}"))) +} + +fn signature_for(timestamp: u64) -> String { + let pre_image = + format!("{timestamp},{SIGNATURE_SECRET},{SITE_ORIGIN},{SIGNATURE_SALT},{timestamp}"); + hex::encode(Sha256::digest(pre_image.as_bytes())) +} + +/// Returns the free `sources` for a slug, best quality first. The auth host +/// occasionally refuses one of a burst of parallel connections, so retry once. +pub async fn fetch_sources(requester: &mut Requester, slug: &str) -> Result> { + match handshake(requester, slug).await { + Ok(sources) => Ok(sources), + Err(_) => { + ntex::time::sleep(ntex::time::Millis(500)).await; + handshake(requester, slug).await + } + } +} + +async fn handshake(requester: &mut Requester, slug: &str) -> Result> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let token = seal_envelope( + json!({ + "timestamp_unix": timestamp, + "directive": "htv_player_handshake", + "slug": slug, + }) + .to_string() + .as_bytes(), + )?; + + let response = requester + .post_json( + HANDSHAKE_URL, + &json!({ "token": token }), + vec![ + ("Origin".to_string(), SITE_ORIGIN.to_string()), + ("Referer".to_string(), format!("{SITE_ORIGIN}/")), + ("Accept".to_string(), "application/json".to_string()), + ("x-signature-version".to_string(), "web2".to_string()), + ("x-signature".to_string(), signature_for(timestamp)), + ("x-time".to_string(), timestamp.to_string()), + ], + ) + .await + .map_err(|e| err(format!("handshake request failed: {e}")))?; + + let status = response.status(); + if !status.is_success() { + return Err(err(format!("handshake returned HTTP {status}"))); + } + + // The interesting part of the answer travels in a response header, not the body. + let encrypted = response + .headers() + .get("x-token") + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_string()) + .ok_or_else(|| err("handshake response is missing x-token"))?; + + let payload: HanimeHandshakePayload = serde_json::from_slice(&open_envelope(&encrypted)?) + .map_err(|e| err(format!("Failed to parse handshake payload: {e}")))?; + + let mut sources: Vec = payload + .sources + .into_iter() + .filter(|s| s.kind == "normal" && !s.src.is_empty()) + .collect(); + sources.sort_by(|a, b| b.height.cmp(&a.height)); + match sources.is_empty() { + true => Err(err("handshake returned no playable sources")), + false => Ok(sources), + } +} + +pub fn source_url(source: &HanimeSource) -> String { + match source.src.starts_with("http") { + true => source.src.clone(), + false => format!("{SITE_ORIGIN}{}", source.src), + } +} + +/// The source of the requested height, else the best one. +pub fn pick_source(sources: &[HanimeSource], height: Option) -> Option<&HanimeSource> { + height + .and_then(|h| sources.iter().find(|s| s.height == h)) + .or_else(|| sources.first()) +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 870998b..8d56d39 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -6,6 +6,7 @@ pub mod discord; pub mod eporner_resolve; pub mod flaresolverr; pub mod flow_debug; +pub mod hanime_resolve; pub mod hoster_proxy; pub mod proxy; pub mod requester;