diff --git a/Cargo.lock b/Cargo.lock index e095c4f..aad3851 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "web-petting" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "chrono-tz", diff --git a/src/admin.rs b/src/admin.rs index 56dc195..6702790 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -193,6 +193,7 @@ struct LoginTemplate<'a> { t: &'a Translations, lang: Lang, error: Option, + turnstile_site_key: String, } #[derive(Debug, Template)] @@ -346,10 +347,12 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re return Redirect::new(format!("/admin/setup?lang={}", lang.code())).into_response(); } + let turnstile_site_key = crate::turnstile::get_site_key(&db).await?; let body = LoginTemplate { t: lang.t(), lang, error: None, + turnstile_site_key, } .render()?; html_response(body, lang) @@ -425,11 +428,25 @@ async fn setup_submit(request: Request, session: Session, db: Database) -> cot:: struct LoginForm { login: String, password: String, + #[serde(default, rename = "cf-turnstile-response")] + cf_turnstile_response: Option, } async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?; + if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? { + let turnstile_site_key = crate::turnstile::get_site_key(&db).await?; + let body = LoginTemplate { + t: lang.t(), + lang, + error: Some(lang.t().login_error.to_string()), + turnstile_site_key, + } + .render()?; + return html_response(body, lang); + } + let login = form.login.clone(); let user = query!(User, $login == login && $status == "active") .get(&db) @@ -448,10 +465,12 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot:: } } + let turnstile_site_key = crate::turnstile::get_site_key(&db).await?; let body = LoginTemplate { t: lang.t(), lang, error: Some(lang.t().login_error.to_string()), + turnstile_site_key, } .render()?; html_response(body, lang) diff --git a/src/main.rs b/src/main.rs index 47d2083..bf7a055 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod migrations; pub mod models; mod public; mod telegram; +mod turnstile; mod tz; use tracing_subscriber; diff --git a/src/public.rs b/src/public.rs index 4363df5..27a56bc 100644 --- a/src/public.rs +++ b/src/public.rs @@ -139,12 +139,7 @@ async fn landing_page(request: Request, db: Database) -> cot::Result { .await? .map(|s| s.value) .unwrap_or_default(); - let turnstile_key = "turnstile_site_key".to_string(); - let turnstile_site_key = query!(Setting, $key == turnstile_key) - .get(&db) - .await? - .map(|s| s.value) - .unwrap_or_default(); + let turnstile_site_key = crate::turnstile::get_site_key(&db).await?; let mut testimonials = Testimonial::objects().all(&db).await?; testimonials.retain(|t| t.status == "active"); testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order)); @@ -180,35 +175,8 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result { let form: LeadForm = serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?; - // Turnstile CAPTCHA verification (only when secret key is configured) - let secret_key_name = "turnstile_secret_key".to_string(); - let secret_key = query!(Setting, $key == secret_key_name) - .get(&db) - .await? - .map(|s| s.value) - .filter(|s| !s.is_empty()); - if let Some(secret) = secret_key { - let token = form.cf_turnstile_response.as_deref().unwrap_or(""); - let client = reqwest::Client::new(); - let resp = client - .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") - .json(&serde_json::json!({ - "secret": secret, - "response": token - })) - .send() - .await; - let verified = match resp { - Ok(r) => r - .json::() - .await - .map(|v| v["success"].as_bool() == Some(true)) - .unwrap_or(false), - Err(_) => false, - }; - if !verified { - return Redirect::new(format!("/?lang={}", lang.code())).into_response(); - } + if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? { + return Redirect::new(format!("/?lang={}", lang.code())).into_response(); } let mut lead = Lead { @@ -256,6 +224,7 @@ struct ClientPortalTemplate<'a> { upcoming: Vec, past: Vec, feedback_sent: bool, + turnstile_site_key: String, } async fn client_portal( @@ -327,6 +296,7 @@ async fn client_portal( } past.reverse(); // newest first + let turnstile_site_key = crate::turnstile::get_site_key(&db).await?; let body = ClientPortalTemplate { t: lang.t(), lang, @@ -334,6 +304,7 @@ async fn client_portal( upcoming, past, feedback_sent, + turnstile_site_key, } .render()?; html_response(body, lang) @@ -342,6 +313,8 @@ async fn client_portal( #[derive(Deserialize)] struct FeedbackForm { feedback: String, + #[serde(default, rename = "cf-turnstile-response")] + cf_turnstile_response: Option, } async fn submit_feedback( @@ -363,6 +336,15 @@ async fn submit_feedback( let form: FeedbackForm = serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?; + if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? { + return Redirect::new(format!( + "/client/{}?lang={}", + token_clone, + lang.code() + )) + .into_response(); + } + if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? { if visit.client_id.primary_key().unwrap() == client_id { visit.client_feedback = Some(form.feedback); diff --git a/src/turnstile.rs b/src/turnstile.rs new file mode 100644 index 0000000..e7d2a77 --- /dev/null +++ b/src/turnstile.rs @@ -0,0 +1,48 @@ +use cot::db::{Database, query}; + +use crate::models::Setting; + +/// Read `turnstile_site_key` from Settings. Returns empty string if not configured. +pub async fn get_site_key(db: &Database) -> cot::Result { + let key = "turnstile_site_key".to_string(); + Ok(query!(Setting, $key == key) + .get(db) + .await? + .map(|s| s.value) + .unwrap_or_default()) +} + +/// Verify a Turnstile token against Cloudflare. +/// Returns `true` if verification succeeds, or if no secret key is configured (passthrough). +pub async fn verify(db: &Database, token: Option<&str>) -> cot::Result { + let secret_key_name = "turnstile_secret_key".to_string(); + let secret_key = query!(Setting, $key == secret_key_name) + .get(db) + .await? + .map(|s| s.value) + .filter(|s| !s.is_empty()); + + let Some(secret) = secret_key else { + return Ok(true); + }; + + let token = token.unwrap_or(""); + let client = reqwest::Client::new(); + let resp = client + .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") + .json(&serde_json::json!({ + "secret": secret, + "response": token + })) + .send() + .await; + + Ok(match resp { + Ok(r) => r + .json::() + .await + .map(|v| v["success"].as_bool() == Some(true)) + .unwrap_or(false), + Err(_) => false, + }) +} diff --git a/templates/admin/login.html b/templates/admin/login.html index e569178..3e8467b 100644 --- a/templates/admin/login.html +++ b/templates/admin/login.html @@ -6,6 +6,9 @@ {{ t.nav_title }} — {{ t.login_title }} + {% if !turnstile_site_key.is_empty() %} + + {% endif %}