Added claudflare Turnstile captcha support

This commit is contained in:
2026-05-18 21:48:02 +03:00
parent 43441ee430
commit 4d41513994
6 changed files with 72 additions and 1 deletions
Generated
+1 -1
View File
@@ -3353,7 +3353,7 @@ dependencies = [
[[package]] [[package]]
name = "web-petting" name = "web-petting"
version = "0.1.9" version = "0.1.10"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz", "chrono-tz",
+4
View File
@@ -798,6 +798,8 @@ struct SettingsForm {
timezone: String, timezone: String,
site_domain: String, site_domain: String,
seo_keywords: String, seo_keywords: String,
turnstile_site_key: String,
turnstile_secret_key: String,
} }
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> { async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
@@ -814,6 +816,8 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
("timezone", form.timezone), ("timezone", form.timezone),
("site_domain", form.site_domain), ("site_domain", form.site_domain),
("seo_keywords", form.seo_keywords), ("seo_keywords", form.seo_keywords),
("turnstile_site_key", form.turnstile_site_key),
("turnstile_secret_key", form.turnstile_secret_key),
] { ] {
let k = key.to_string(); let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?; let existing = query!(Setting, $key == k).get(&db).await?;
+6
View File
@@ -135,6 +135,8 @@ pub struct Translations {
pub settings_timezone: &'static str, pub settings_timezone: &'static str,
pub settings_site_domain: &'static str, pub settings_site_domain: &'static str,
pub settings_seo_keywords: &'static str, pub settings_seo_keywords: &'static str,
pub settings_turnstile_site_key: &'static str,
pub settings_turnstile_secret_key: &'static str,
pub landing_contact_label: &'static str, pub landing_contact_label: &'static str,
pub landing_pricing_title: &'static str, pub landing_pricing_title: &'static str,
@@ -348,6 +350,8 @@ static RU: Translations = Translations {
settings_timezone: "Часовой пояс (например Asia/Vladivostok)", settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
settings_site_domain: "Домен сайта (например https://example.com)", settings_site_domain: "Домен сайта (например https://example.com)",
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)", settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key (ключ виджета)",
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key (секретный ключ)",
landing_contact_label: "Или свяжитесь с нами напрямую", landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость", landing_pricing_title: "Стоимость",
@@ -551,6 +555,8 @@ static EN: Translations = Translations {
settings_timezone: "Timezone (e.g. Asia/Vladivostok)", settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
settings_site_domain: "Site domain (e.g. https://example.com)", settings_site_domain: "Site domain (e.g. https://example.com)",
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)", settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key",
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
landing_contact_label: "Or contact us directly", landing_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing", landing_pricing_title: "Pricing",
+41
View File
@@ -76,6 +76,7 @@ struct LandingTemplate<'a> {
testimonials: Vec<Testimonial>, testimonials: Vec<Testimonial>,
site_domain: String, site_domain: String,
review_count: usize, review_count: usize,
turnstile_site_key: String,
} }
#[derive(Debug, Template)] #[derive(Debug, Template)]
@@ -138,6 +139,12 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
.await? .await?
.map(|s| s.value) .map(|s| s.value)
.unwrap_or_default(); .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 mut testimonials = Testimonial::objects().all(&db).await?; let mut testimonials = Testimonial::objects().all(&db).await?;
testimonials.retain(|t| t.status == "active"); testimonials.retain(|t| t.status == "active");
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order)); testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
@@ -151,6 +158,7 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
testimonials, testimonials,
site_domain, site_domain,
review_count, review_count,
turnstile_site_key,
} }
.render()?; .render()?;
html_response(body, lang) html_response(body, lang)
@@ -161,6 +169,8 @@ struct LeadForm {
name: String, name: String,
phone: Option<String>, phone: Option<String>,
comment: Option<String>, comment: Option<String>,
#[serde(default, rename = "cf-turnstile-response")]
cf_turnstile_response: Option<String>,
} }
async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> { async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
@@ -170,6 +180,37 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
let form: LeadForm = let form: LeadForm =
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?; 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();
}
}
let mut lead = Lead { let mut lead = Lead {
id: Auto::auto(), id: Auto::auto(),
name: form.name, name: form.name,
+13
View File
@@ -55,6 +55,19 @@
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p> <p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
</div> </div>
<div class="field">
<label class="label">{{ t.settings_turnstile_site_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button> <button type="submit" class="button is-primary">{{ t.settings_save }}</button>
</form> </form>
</div> </div>
+7
View File
@@ -50,6 +50,10 @@
} }
</script> </script>
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style> <style>
/* ── Reset & Base ── */ /* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -464,6 +468,9 @@
<input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;"> <input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;">
<label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label> <label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label>
</div> </div>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-bottom:1.25rem;"></div>
{% endif %}
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button> <button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
</form> </form>
{% if !contact_info.is_empty() %} {% if !contact_info.is_empty() %}