use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlUnshortenRequest {
pub url: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlUnshortenResult {
pub original_url: String,
pub final_url: String,
pub redirect_chain: Vec<String>,
pub hop_count: usize,
pub is_shortened: bool,
pub final_domain: Option<String>,
pub threat_found: Option<String>,
pub error: Option<String>,
}
pub async fn check_url_unshorten(req: &UrlUnshortenRequest) -> Result<UrlUnshortenResult> {
crate::api::helpers::validate_url_safety(&req.url)
.map_err(crate::error::ShoheError::Parse)?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(req.timeout_secs))
.redirect(reqwest::redirect::Policy::limited(20))
.build()
.map_err(|e| crate::error::ShoheError::Transport(e.to_string()))?;
let response = match client.head(&req.url).send().await {
Ok(r) => r,
Err(e) => {
return Ok(UrlUnshortenResult {
original_url: req.url.clone(),
final_url: String::new(),
redirect_chain: vec![],
hop_count: 0,
is_shortened: false,
final_domain: None,
threat_found: None,
error: Some(e.to_string()),
});
}
};
let final_url = response.url().to_string();
let is_shortened = final_url != req.url;
let final_domain = url::Url::parse(&final_url)
.ok()
.and_then(|u| u.domain().map(|d| d.to_string()));
let redirect_chain = vec![req.url.clone(), final_url.clone()];
let hop_count = redirect_chain.len() - 1;
Ok(UrlUnshortenResult {
original_url: req.url.clone(),
final_url,
redirect_chain,
hop_count,
is_shortened,
final_domain,
threat_found: None,
error: None,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ja4hFingerprintRequest {
pub url: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ja4hFingerprintResult {
pub url: String,
pub ja4h_hash: String,
pub client_type: String,
pub accept_language: Option<String>,
pub accept_encoding: Option<String>,
pub user_agent: Option<String>,
pub header_count: usize,
pub error: Option<String>,
}
pub async fn check_ja4h_fingerprint(req: &Ja4hFingerprintRequest) -> Result<Ja4hFingerprintResult> {
crate::api::helpers::validate_url_safety(&req.url)
.map_err(crate::error::ShoheError::Parse)?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(req.timeout_secs))
.build()
.map_err(|e| crate::error::ShoheError::Transport(e.to_string()))?;
let response = match client.head(&req.url).send().await {
Ok(r) => r,
Err(e) => {
return Ok(Ja4hFingerprintResult {
url: req.url.clone(),
ja4h_hash: String::new(),
client_type: "unknown".to_string(),
accept_language: None,
accept_encoding: None,
user_agent: None,
header_count: 0,
error: Some(e.to_string()),
});
}
};
let mut headers: Vec<String> = response
.headers()
.iter()
.map(|(k, _)| k.to_string().to_lowercase())
.collect();
headers.sort();
let accept_language = response
.headers()
.get("accept-language")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let accept_encoding = response
.headers()
.get("accept-encoding")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let user_agent = response
.headers()
.get("user-agent")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let header_order = headers.join(",");
let ja4h_hash = {
use sha2::Digest;
let hash = sha2::Sha256::digest(header_order.as_bytes());
format!("ja4h_{}", crate::api::helpers::hex_encode(&hash[..6]))
};
let client_type = if let Some(ua) = &user_agent {
if ua.contains("Chrome") {
"Chrome".to_string()
} else if ua.contains("Firefox") {
"Firefox".to_string()
} else if ua.contains("Safari") {
"Safari".to_string()
} else if ua.contains("Edge") {
"Edge".to_string()
} else {
"Other".to_string()
}
} else {
"Unknown".to_string()
};
Ok(Ja4hFingerprintResult {
url: req.url.clone(),
ja4h_hash,
client_type,
accept_language,
accept_encoding,
user_agent,
header_count: response.headers().len(),
error: None,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlSafetyMultiRequest {
pub url: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlSafetyMultiResult {
pub url: String,
pub is_safe: bool,
pub threat_sources: Vec<String>,
pub urlhaus_threat: bool,
pub phishing_score: u8,
pub domain_age_days: Option<i64>,
pub overall_risk: String,
pub recommendations: Vec<String>,
}
pub async fn check_url_safety_multi(req: &UrlSafetyMultiRequest) -> Result<UrlSafetyMultiResult> {
let mut threat_sources = Vec::new();
let mut urlhaus_threat = false;
if let Ok(result) = crate::api::check_url_reputation(&crate::api::UrlhausRequest {
url: req.url.clone(),
timeout_secs: req.timeout_secs,
})
.await
{
if result.is_malicious {
threat_sources.push("URLhaus".to_string());
urlhaus_threat = true;
}
}
let phishing_score = if urlhaus_threat { 85u8 } else { 15u8 };
let is_safe = threat_sources.is_empty() && phishing_score < 50;
let overall_risk = match phishing_score {
0..=25 => "safe".to_string(),
26..=50 => "low".to_string(),
51..=75 => "medium".to_string(),
_ => "high".to_string(),
};
let recommendations = if is_safe {
vec!["URL appears safe".to_string()]
} else {
vec!["Exercise caution when visiting this URL".to_string()]
};
Ok(UrlSafetyMultiResult {
url: req.url.clone(),
is_safe,
threat_sources,
urlhaus_threat,
phishing_score,
domain_age_days: None,
overall_risk,
recommendations,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlRedirectThreatRequest {
pub url: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlRedirectThreatResult {
pub url: String,
pub has_open_redirect: bool,
pub has_protocol_downgrade: bool,
pub cross_domain_redirects: Vec<String>,
pub threat_level: String,
pub redirect_chain: Vec<String>,
pub error: Option<String>,
}
pub async fn check_url_redirect_threat(req: &UrlRedirectThreatRequest) -> Result<UrlRedirectThreatResult> {
let redirect_req = crate::api::RedirectChainRequest {
url: req.url.clone(),
timeout_secs: req.timeout_secs,
check_domain_age: false,
max_hops: 20,
};
match crate::api::check_redirect_chain(&redirect_req).await {
Ok(result) => {
let mut has_protocol_downgrade = false;
let mut cross_domain_redirects = Vec::new();
let chain = result.hops.iter().map(|h| h.url.clone()).collect::<Vec<_>>();
for i in 0..chain.len().saturating_sub(1) {
if chain[i].starts_with("https://") && chain[i + 1].starts_with("http://") {
has_protocol_downgrade = true;
}
if let (Ok(url1), Ok(url2)) = (
url::Url::parse(&chain[i]),
url::Url::parse(&chain[i + 1]),
) {
if url1.domain() != url2.domain() {
cross_domain_redirects.push(format!("{} → {}", chain[i], chain[i + 1]));
}
}
}
let has_open_redirect = result.has_loop || has_protocol_downgrade;
let threat_level = if has_protocol_downgrade {
"high".to_string()
} else if !cross_domain_redirects.is_empty() {
"medium".to_string()
} else {
"low".to_string()
};
Ok(UrlRedirectThreatResult {
url: req.url.clone(),
has_open_redirect,
has_protocol_downgrade,
cross_domain_redirects,
threat_level,
redirect_chain: chain,
error: None,
})
}
Err(_) => Ok(UrlRedirectThreatResult {
url: req.url.clone(),
has_open_redirect: false,
has_protocol_downgrade: false,
cross_domain_redirects: Vec::new(),
threat_level: "unknown".to_string(),
redirect_chain: Vec::new(),
error: Some("Failed to trace redirect chain".to_string()),
}),
}
}
fn default_timeout() -> u64 {
crate::api::helpers::DEFAULT_REQUEST_TIMEOUT_SECS
}