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::Cap {
48 api_url,
49 site_key,
50 secret_key,
51 } => {
52 let response = CLIENT
53 .post(format!(
54 "{}/{site_key}/siteverify",
55 api_url.trim_end_matches('/')
56 ))
57 .json(&serde_json::json!({
58 "secret": secret_key,
59 "response": captcha,
60 }))
61 .send()
62 .await
63 .map_err(|err| {
64 tracing::error!("captcha: cap verification request failed: {:?}", err);
65 err.to_compact_string()
66 })?;
67
68 if response.status().is_success() {
69 let body: serde_json::Value = response.json().await.map_err(|err| {
70 tracing::error!(
71 "captcha: cap verification response parsing failed: {:?}",
72 err
73 );
74 err.to_compact_string()
75 })?;
76 if let Some(success) = body.get("success")
77 && success.as_bool().unwrap_or(false)
78 {
79 return Ok(());
80 }
81 }
82
83 Err("captcha: verification failed".into())
84 }
85 super::settings::CaptchaProvider::Turnstile { secret_key, .. } => {
86 let response = CLIENT
87 .post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
88 .json(&serde_json::json!({
89 "secret": secret_key,
90 "response": captcha,
91 "remoteip": ip.to_string(),
92 }))
93 .send()
94 .await
95 .map_err(|err| {
96 tracing::error!(
97 "captcha: turnstile verification request failed: {:?}",
98 err
99 );
100 err.to_compact_string()
101 })?;
102
103 if response.status().is_success() {
104 let body: serde_json::Value = response.json().await.map_err(|err| {
105 tracing::error!(
106 "captcha: turnstile verification response parsing failed: {:?}",
107 err
108 );
109 err.to_compact_string()
110 })?;
111 if let Some(success) = body.get("success")
112 && success.as_bool().unwrap_or(false)
113 {
114 return Ok(());
115 }
116 }
117
118 Err("captcha: verification failed".into())
119 }
120 super::settings::CaptchaProvider::Recaptcha {
121 v3,
122 threshold,
123 secret_key,
124 ..
125 } => {
126 let response = CLIENT
127 .post("https://www.google.com/recaptcha/api/siteverify")
128 .form(&[
129 ("secret", secret_key.as_str()),
130 ("response", captcha.as_str()),
131 ("remoteip", ip.to_string().as_str()),
132 ])
133 .send()
134 .await
135 .map_err(|err| {
136 tracing::error!(
137 "captcha: recaptcha verification request failed: {:?}",
138 err
139 );
140 err.to_compact_string()
141 })?;
142
143 if response.status().is_success() {
144 let body: serde_json::Value = response.json().await.map_err(|err| {
145 tracing::error!(
146 "captcha: recaptcha verification response parsing failed: {:?}",
147 err
148 );
149 err.to_compact_string()
150 })?;
151 if let Some(success) = body.get("success")
152 && success.as_bool().unwrap_or(false)
153 {
154 if *v3 {
155 if let Some(score) =
156 body.get("score").and_then(serde_json::Value::as_f64)
157 && score >= *threshold
158 {
159 return Ok(());
160 }
161 } else {
162 return Ok(());
163 }
164 }
165 }
166
167 Err("captcha: verification failed".into())
168 }
169 super::settings::CaptchaProvider::Hcaptcha {
170 secret_key,
171 site_key,
172 } => {
173 let response = CLIENT
174 .post("https://hcaptcha.com/siteverify")
175 .form(&[
176 ("secret", secret_key.as_str()),
177 ("sitekey", site_key.as_str()),
178 ("response", captcha.as_str()),
179 ("remoteip", ip.to_string().as_str()),
180 ])
181 .send()
182 .await
183 .map_err(|err| {
184 tracing::error!("captcha: hcaptcha verification request failed: {:?}", err);
185 err.to_compact_string()
186 })?;
187
188 if response.status().is_success() {
189 let body: serde_json::Value = response.json().await.map_err(|err| {
190 tracing::error!(
191 "captcha: hcaptcha verification response parsing failed: {:?}",
192 err
193 );
194 err.to_compact_string()
195 })?;
196 if let Some(success) = body.get("success")
197 && success.as_bool().unwrap_or(false)
198 {
199 return Ok(());
200 }
201 }
202
203 Err("captcha: verification failed".into())
204 }
205 super::settings::CaptchaProvider::FriendlyCaptcha { api_key, site_key } => {
206 let response = CLIENT
207 .post("https://global.frcapi.com/api/v2/captcha/siteverify")
208 .header("X-API-Key", api_key.as_str())
209 .json(&serde_json::json!({
210 "sitekey": site_key.as_str(),
211 "response": captcha.as_str(),
212 }))
213 .send()
214 .await
215 .map_err(|err| {
216 tracing::error!(
217 "captcha: friendlycaptcha verification request failed: {:?}",
218 err
219 );
220 err.to_compact_string()
221 })?;
222
223 if response.status().is_success() {
224 let body: serde_json::Value = response.json().await.map_err(|err| {
225 tracing::error!(
226 "captcha: friendlycaptcha verification response parsing failed: {:?}",
227 err
228 );
229 err.to_compact_string()
230 })?;
231 if let Some(success) = body.get("success")
232 && success.as_bool().unwrap_or(false)
233 {
234 return Ok(());
235 }
236 }
237
238 Err("captcha: verification failed".into())
239 }
240 }
241 }
242}