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:
@@ -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
71
src/proxies/hanime.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
208
src/util/hanime_resolve.rs
Normal 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())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user