1use compact_str::ToCompactString;
2use std::sync::{Arc, LazyLock};
3
4static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
5 reqwest::Client::builder()
6 .user_agent(format!("github.com/calagopus/panel {}", crate::VERSION))
7 .build()
8 .expect("Failed to create HTTP client")
9});
10
11pub struct Captcha {
12 settings: Arc<super::settings::Settings>,
13}
14
15impl Captcha {
16 pub fn new(settings: Arc<super::settings::Settings>) -> Self {
17 Self { settings }
18 }
19
20 pub async fn verify(
21 &self,
22 ip: crate::GetIp,
23 captcha: Option<String>,
24 ) -> Result<(), compact_str::CompactString> {
25 let settings = self
26 .settings
27 .get()
28 .await
29 .map_err(|e| e.to_compact_string())?;
30
31 let captcha = match captcha {
32 Some(c) => c,
33 None => {
34 if matches!(
35 settings.captcha_provider,
36 super::settings::CaptchaProvider::None
37 ) {
38 return Ok(());
39 } else {
40 return Err("captcha: required".into());
41 }
42 }
43 };
44
45 match &settings.captcha_provider {
46 super::settings::CaptchaProvider::None => Ok(()),
47 super::settings::CaptchaProvider::Turnstile { secret_key, .. } => {
48 let response = CLIENT
49 .post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
50 .json(&serde_json::json!({
51 "secret": secret_key,
52 "response": captcha,
53 "remoteip": ip.to_string(),
54 }))
55 .send()
56 .await
57 .map_err(|e| e.to_compact_string())?;
58
59 if response.status().is_success() {
60 let body: serde_json::Value =
61 response.json().await.map_err(|e| e.to_compact_string())?;
62 if let Some(success) = body.get("success")
63 && success.as_bool().unwrap_or(false)
64 {
65 return Ok(());
66 }
67 }
68
69 Err("captcha: verification failed".into())
70 }
71 super::settings::CaptchaProvider::Recaptcha { v3, secret_key, .. } => {
72 let response = CLIENT
73 .post("https://www.google.com/recaptcha/api/siteverify")
74 .form(&[
75 ("secret", secret_key.as_str()),
76 ("response", captcha.as_str()),
77 ("remoteip", ip.to_string().as_str()),
78 ])
79 .send()
80 .await
81 .map_err(|e| e.to_compact_string())?;
82
83 if response.status().is_success() {
84 let body: serde_json::Value =
85 response.json().await.map_err(|e| e.to_compact_string())?;
86 if let Some(success) = body.get("success")
87 && success.as_bool().unwrap_or(false)
88 {
89 if *v3 {
90 if let Some(score) = body.get("score")
91 && score.as_f64().unwrap_or(0.0) >= 0.5
92 {
93 return Ok(());
94 }
95 } else {
96 return Ok(());
97 }
98 }
99 }
100
101 Err("captcha: verification failed".into())
102 }
103 super::settings::CaptchaProvider::Hcaptcha {
104 secret_key,
105 site_key,
106 } => {
107 let response = CLIENT
108 .post("https://hcaptcha.com/siteverify")
109 .form(&[
110 ("secret", secret_key.as_str()),
111 ("sitekey", site_key.as_str()),
112 ("response", captcha.as_str()),
113 ("remoteip", ip.to_string().as_str()),
114 ])
115 .send()
116 .await
117 .map_err(|e| e.to_compact_string())?;
118
119 if response.status().is_success() {
120 let body: serde_json::Value =
121 response.json().await.map_err(|e| e.to_compact_string())?;
122 if let Some(success) = body.get("success")
123 && success.as_bool().unwrap_or(false)
124 {
125 return Ok(());
126 }
127 }
128
129 Err("captcha: verification failed".into())
130 }
131 super::settings::CaptchaProvider::FriendlyCaptcha { api_key, site_key } => {
132 let response = CLIENT
133 .post("https://global.frcapi.com/api/v2/captcha/siteverify")
134 .header("X-API-Key", api_key.as_str())
135 .json(&serde_json::json!({
136 "sitekey": site_key.as_str(),
137 "response": captcha.as_str(),
138 }))
139 .send()
140 .await
141 .map_err(|e| e.to_compact_string())?;
142
143 if response.status().is_success() {
144 let body: serde_json::Value =
145 response.json().await.map_err(|e| e.to_compact_string())?;
146 if let Some(success) = body.get("success")
147 && success.as_bool().unwrap_or(false)
148 {
149 return Ok(());
150 }
151 }
152
153 Err("captcha: verification failed".into())
154 }
155 }
156 }
157}