Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8de7cfa33 | |||
| f7dcefeea6 | |||
| 757ebea2ba | |||
| 4d41513994 |
Generated
+1
-1
@@ -3353,7 +3353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-petting"
|
||||
version = "0.1.9"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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)
|
||||
@@ -798,6 +817,8 @@ struct SettingsForm {
|
||||
timezone: String,
|
||||
site_domain: 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> {
|
||||
@@ -814,6 +835,8 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
("timezone", form.timezone),
|
||||
("site_domain", form.site_domain),
|
||||
("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 existing = query!(Setting, $key == k).get(&db).await?;
|
||||
|
||||
@@ -135,6 +135,8 @@ pub struct Translations {
|
||||
pub settings_timezone: &'static str,
|
||||
pub settings_site_domain: &'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_pricing_title: &'static str,
|
||||
|
||||
@@ -348,6 +350,8 @@ static RU: Translations = Translations {
|
||||
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||
settings_site_domain: "Домен сайта (например https://example.com)",
|
||||
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_pricing_title: "Стоимость",
|
||||
|
||||
@@ -551,6 +555,8 @@ static EN: Translations = Translations {
|
||||
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||
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_turnstile_site_key: "Cloudflare Turnstile — Site Key",
|
||||
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
landing_pricing_title: "Pricing",
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod migrations;
|
||||
pub mod models;
|
||||
mod public;
|
||||
mod telegram;
|
||||
mod turnstile;
|
||||
mod tz;
|
||||
|
||||
use tracing_subscriber;
|
||||
|
||||
@@ -76,6 +76,7 @@ struct LandingTemplate<'a> {
|
||||
testimonials: Vec<Testimonial>,
|
||||
site_domain: String,
|
||||
review_count: usize,
|
||||
turnstile_site_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
@@ -138,6 +139,7 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
.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));
|
||||
@@ -151,6 +153,7 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
testimonials,
|
||||
site_domain,
|
||||
review_count,
|
||||
turnstile_site_key,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -161,6 +164,8 @@ struct LeadForm {
|
||||
name: String,
|
||||
phone: 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> {
|
||||
@@ -170,6 +175,10 @@ 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()))?;
|
||||
|
||||
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 {
|
||||
id: Auto::auto(),
|
||||
name: form.name,
|
||||
@@ -215,6 +224,7 @@ struct ClientPortalTemplate<'a> {
|
||||
upcoming: Vec<PortalVisit>,
|
||||
past: Vec<PortalVisit>,
|
||||
feedback_sent: bool,
|
||||
turnstile_site_key: String,
|
||||
}
|
||||
|
||||
async fn client_portal(
|
||||
@@ -286,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,
|
||||
@@ -293,6 +304,7 @@ async fn client_portal(
|
||||
upcoming,
|
||||
past,
|
||||
feedback_sent,
|
||||
turnstile_site_key,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -301,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(
|
||||
@@ -322,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);
|
||||
|
||||
@@ -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,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" data-size="compact" style="margin-top:0.75rem;"></div>
|
||||
{% endif %}
|
||||
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,19 @@
|
||||
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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" data-appearance="interaction-only" 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" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
|
||||
{% endif %}
|
||||
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* ── Reset & Base ── */
|
||||
*, *::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;">
|
||||
<label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label>
|
||||
</div>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-bottom:1.25rem;"></div>
|
||||
{% endif %}
|
||||
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
|
||||
</form>
|
||||
{% if !contact_info.is_empty() %}
|
||||
|
||||
Reference in New Issue
Block a user