Added claudflare Turnstile captcha support
Build and Publish / Build and Publish Docker Image (push) Successful in 7m6s

This commit is contained in:
2026-05-18 22:12:54 +03:00
parent 757ebea2ba
commit f7dcefeea6
7 changed files with 101 additions and 36 deletions
Generated
+1 -1
View File
@@ -3353,7 +3353,7 @@ dependencies = [
[[package]]
name = "web-petting"
version = "0.1.10"
version = "0.1.11"
dependencies = [
"chrono",
"chrono-tz",
+19
View File
@@ -193,6 +193,7 @@ struct LoginTemplate<'a> {
t: &'a Translations,
lang: Lang,
error: Option<String>,
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<String>,
}
async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
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)
+1
View File
@@ -4,6 +4,7 @@ mod migrations;
pub mod models;
mod public;
mod telegram;
mod turnstile;
mod tz;
use tracing_subscriber;
+17 -35
View File
@@ -139,12 +139,7 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
.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<Response> {
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::<serde_json::Value>()
.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<PortalVisit>,
past: Vec<PortalVisit>,
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<String>,
}
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);
+48
View File
@@ -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<String> {
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<bool> {
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::<serde_json::Value>()
.await
.map(|v| v["success"].as_bool() == Some(true))
.unwrap_or(false),
Err(_) => false,
})
}
+6
View File
@@ -6,6 +6,9 @@
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style>
:root { color-scheme: light; }
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
@@ -41,6 +44,9 @@
<label class="label">{{ t.users_password }}</label>
<div class="control"><input class="input" type="password" name="password" required></div>
</div>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
{% endif %}
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
</form>
</div>
+9
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.portal_title }} — {{ client.name }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style>
:root { color-scheme: light; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -166,6 +169,9 @@
</div>
<form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" required>{{ fb }}</textarea>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.4rem;"></div>
{% endif %}
<div style="display:flex;gap:0.4rem;">
<button type="submit">{{ t.portal_feedback_submit }}</button>
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})"></button>
@@ -174,6 +180,9 @@
{% else %}
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.4rem;"></div>
{% endif %}
<button type="submit">{{ t.portal_feedback_submit }}</button>
</form>
{% endif %}