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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
Simon
2026-11-01 07:48:35 +00:00
parent aa00abdd5b
commit cf0ae15f93
6 changed files with 312 additions and 195 deletions

View File

@@ -1,13 +1,8 @@
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use async_trait::async_trait; use async_trait::async_trait;
use base64::Engine;
use error_chain::error_chain; use error_chain::error_chain;
use futures::future::join_all; use futures::future::join_all;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::SystemTime;
use std::vec; use std::vec;
use crate::DbPool; use crate::DbPool;
@@ -16,6 +11,7 @@ use crate::db;
use crate::providers::{Provider, report_provider_error, report_provider_error_background}; use crate::providers::{Provider, report_provider_error, report_provider_error_background};
use crate::status::*; use crate::status::*;
use crate::util::cache::VideoCache; use crate::util::cache::VideoCache;
use crate::util::hanime_resolve::{self as resolve, HanimeSource, SITE_ORIGIN};
use crate::videos::{self, ServerOptions, VideoItem}; use crate::videos::{self, ServerOptions, VideoItem};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
@@ -34,14 +30,6 @@ error_chain! {
/// Full catalogue dump. Ignores every query parameter, so filtering, sorting and /// Full catalogue dump. Ignores every query parameter, so filtering, sorting and
/// pagination all happen client side. ~860 KB compressed, CDN cached for an hour. /// 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 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; const INDEX_TTL_SECS: u64 = 3600;
type IndexCache = OnceLock<Mutex<Option<(SystemTime, Arc<Vec<HanimeSearchResult>>)>>>; type IndexCache = OnceLock<Mutex<Option<(SystemTime, Arc<Vec<HanimeSearchResult>>)>>>;
@@ -76,99 +64,6 @@ struct HanimeSearchResult {
released_at_unix: u64, released_at_unix: u64,
} }
#[derive(serde::Deserialize, Debug)]
struct HanimeHandshakePayload {
#[serde(default)]
sources: Vec<HanimeSource>,
}
#[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<Vec<u8>> {
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::<Aes256Gcm>::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<String> {
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<Vec<u8>> {
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<Vec<u8>> {
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)] #[derive(Debug, Clone)]
pub struct HanimeProvider; 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<Vec<HanimeSource>> {
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 { fn db_key(slug: &str) -> String {
format!("https://hanime.tv/videos/hentai/{slug}") format!("https://hanime.tv/videos/hentai/{slug}")
} }
@@ -319,86 +233,6 @@ impl HanimeProvider {
Ok(items) 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<Vec<HanimeSource>> {
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<Vec<HanimeSource>> {
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<HanimeSource> = 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 { fn quality_label(source: &HanimeSource) -> String {
match source.label.trim_end_matches('p') { match source.label.trim_end_matches('p') {
"" => source.height.to_string(), "" => source.height.to_string(),
@@ -452,15 +286,15 @@ impl HanimeProvider {
.iter() .iter()
.map(|source| { .map(|source| {
videos::VideoFormat::new( videos::VideoFormat::new(
Self::source_url(source), Self::proxied_url(&options, &hit.slug, source),
Self::quality_label(source), Self::quality_label(source),
"m3u8".to_string(), "m3u8".to_string(),
) )
}) })
.collect(); .collect();
let stream_url = Self::source_url(&sources[0]); let stream_url = Self::proxied_url(&options, &hit.slug, &sources[0]);
let duration = self let duration = self
.fetch_duration(&stream_url, &options) .fetch_duration(&resolve::source_url(&sources[0]), &options)
.await .await
.unwrap_or_default(); .unwrap_or_default();
if let Ok(mut conn) = pool.get() { 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()); .and_then(|mut conn| db::get_video(&mut conn, db_key.clone()).ok().flatten());
match db_result { match db_result {
Some(video_url) if video_url.contains("m3u8") || video_url.contains("/hls/") => { Some(video_url) if video_url.contains("/proxy/hanime/") => {
let duration = self let duration = 0;
.fetch_duration(&video_url, &options)
.await
.unwrap_or_default();
let formats = vec![videos::VideoFormat::new( let formats = vec![videos::VideoFormat::new(
video_url.clone(), video_url.clone(),
"720".to_string(), "720".to_string(),

71
src/proxies/hanime.rs Normal file
View File

@@ -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<u32>)> {
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::<u32>().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<Requester>,
) -> Result<impl web::Responder, web::Error> {
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);
}
}

View File

@@ -26,6 +26,7 @@ pub mod clapdat;
pub mod doodstream; pub mod doodstream;
pub mod eporner; pub mod eporner;
pub mod fikfapthumb; pub mod fikfapthumb;
pub mod hanime;
pub mod hanimecdn; pub mod hanimecdn;
pub mod hanimethumb; pub mod hanimethumb;
pub mod heavyfetish; pub mod heavyfetish;

View File

@@ -157,6 +157,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route(web::post().to(crate::proxies::fikfapthumb::get_image)) .route(web::post().to(crate::proxies::fikfapthumb::get_image))
.route(web::get().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( .service(
web::resource("/hanime-cdn/{endpoint}*") web::resource("/hanime-cdn/{endpoint}*")
.route(web::post().to(crate::proxies::hanimecdn::get_image)) .route(web::post().to(crate::proxies::hanimecdn::get_image))

208
src/util/hanime_resolve.rs Normal file
View File

@@ -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=<id>` 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<T> = std::result::Result<T, String>;
fn err(message: impl Into<String>) -> String {
message.into()
}
#[derive(serde::Deserialize, Debug)]
pub struct HanimeHandshakePayload {
#[serde(default)]
pub sources: Vec<HanimeSource>,
}
#[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<Vec<u8>> {
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::<Aes256Gcm>::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<String> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<HanimeSource>> {
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<Vec<HanimeSource>> {
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<HanimeSource> = 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<u32>) -> Option<&HanimeSource> {
height
.and_then(|h| sources.iter().find(|s| s.height == h))
.or_else(|| sources.first())
}

View File

@@ -6,6 +6,7 @@ pub mod discord;
pub mod eporner_resolve; pub mod eporner_resolve;
pub mod flaresolverr; pub mod flaresolverr;
pub mod flow_debug; pub mod flow_debug;
pub mod hanime_resolve;
pub mod hoster_proxy; pub mod hoster_proxy;
pub mod proxy; pub mod proxy;
pub mod requester; pub mod requester;