9 Commits

Author SHA1 Message Date
ab a65488c304 Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m49s
2026-05-19 00:57:05 +03:00
ab 4d9d0a894c Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 2m53s
2026-05-19 00:32:36 +03:00
ab fd1e78ba8c Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m49s
2026-05-19 00:16:22 +03:00
ab 99e2cbc1f0 Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m51s
2026-05-18 23:50:34 +03:00
ab 71f444b9aa Added claudflare Turnstile captcha support 2026-05-18 23:09:07 +03:00
ab a8de7cfa33 Added claudflare Turnstile captcha support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s
2026-05-18 22:30:36 +03:00
ab f7dcefeea6 Added claudflare Turnstile captcha support
Build and Publish / Build and Publish Docker Image (push) Successful in 7m6s
2026-05-18 22:12:54 +03:00
ab 757ebea2ba Added claudflare Turnstile captcha support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m56s
2026-05-18 21:48:30 +03:00
ab 4d41513994 Added claudflare Turnstile captcha support 2026-05-18 21:48:02 +03:00
11 changed files with 660 additions and 25 deletions
Generated
+9 -1
View File
@@ -3182,6 +3182,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3353,8 +3359,9 @@ dependencies = [
[[package]]
name = "web-petting"
version = "0.1.9"
version = "0.1.12"
dependencies = [
"base64",
"chrono",
"chrono-tz",
"cot",
@@ -3368,6 +3375,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
]
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "web-petting"
version = "0.1.10"
version = "0.1.12"
edition = "2024"
[dependencies]
@@ -16,5 +16,7 @@ multer = "3"
futures = "0.3"
tokio = { version = "1", features = ["fs"] }
uuid = { version = "1", features = ["v4"] }
base64 = "0.22"
urlencoding = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+406 -1
View File
@@ -88,6 +88,15 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
.unwrap_or(false)
}
fn get_query_param(request: &Request, key: &str) -> Option<String> {
let prefix = format!("{}=", key);
request.uri().query().and_then(|q| {
q.split('&').find_map(|p| {
p.strip_prefix(&prefix).map(|v| v.to_string())
})
})
}
/// Soft pastel palette for client calendar colors.
const CLIENT_COLORS: &[&str] = &[
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
@@ -193,6 +202,9 @@ struct LoginTemplate<'a> {
t: &'a Translations,
lang: Lang,
error: Option<String>,
turnstile_site_key: String,
auth_password_enabled: bool,
auth_sso_enabled: bool,
}
#[derive(Debug, Template)]
@@ -254,6 +266,8 @@ struct SettingsTemplate<'a> {
admin_name: &'a str,
settings: Vec<Setting>,
saved: bool,
auth_password_checked: bool,
auth_sso_checked: bool,
}
#[derive(Debug, Template)]
@@ -346,10 +360,55 @@ 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 settings = Setting::objects().all(&db).await?;
let get_val = |key: &str| -> String {
settings
.iter()
.find(|s| s.key == key)
.map(|s| s.value.clone())
.unwrap_or_default()
};
let password_setting = get_val("auth_password_enabled");
let sso_setting = get_val("auth_sso_enabled");
let oidc_configured = !get_val("oidc_issuer_url").trim().is_empty();
// Default: password enabled if setting was never saved
let auth_password_enabled = if password_setting.is_empty() {
true
} else {
password_setting == "true"
};
let auth_sso_enabled = sso_setting == "true" && oidc_configured;
// Fallback: if neither is enabled, show password form
let (auth_password_enabled, auth_sso_enabled) = if !auth_password_enabled && !auth_sso_enabled {
(true, false)
} else {
(auth_password_enabled, auth_sso_enabled)
};
let error = get_query_param(&request, "error").map(|code| {
let t = lang.t();
match code.as_str() {
"sso_group" => t.login_sso_error_group,
"sso_provider" => t.login_sso_error_provider,
"sso_disabled" => t.login_sso_error_user_disabled,
"sso" => t.login_sso_error,
_ => t.login_sso_error,
}
.to_string()
});
let body = LoginTemplate {
t: lang.t(),
lang,
error: None,
error,
turnstile_site_key,
auth_password_enabled,
auth_sso_enabled,
}
.render()?;
html_response(body, lang)
@@ -425,11 +484,27 @@ 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,
auth_password_enabled: true,
auth_sso_enabled: false,
}
.render()?;
return html_response(body, lang);
}
let login = form.login.clone();
let user = query!(User, $login == login && $status == "active")
.get(&db)
@@ -448,10 +523,14 @@ 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,
auth_password_enabled: true,
auth_sso_enabled: false,
}
.render()?;
html_response(body, lang)
@@ -463,6 +542,274 @@ async fn logout(request: Request, session: Session) -> cot::Result<Response> {
Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response()
}
// ---------------------------------------------------------------------------
// OIDC Handlers
// ---------------------------------------------------------------------------
/// Read an OIDC-related setting from the DB, returning empty string if absent.
async fn oidc_setting(db: &Database, name: &str) -> cot::Result<String> {
let k = name.to_string();
Ok(query!(Setting, $key == k)
.get(db)
.await?
.map(|s| s.value)
.unwrap_or_default())
}
/// Fetch the OpenID Connect discovery document and extract a field.
async fn oidc_discover(issuer_url: &str, field: &str) -> Option<String> {
let url = format!(
"{}/.well-known/openid-configuration",
issuer_url.trim_end_matches('/')
);
let resp = reqwest::Client::new().get(&url).send().await.ok()?;
let json: serde_json::Value = resp.json().await.ok()?;
json.get(field)?.as_str().map(|s| s.to_string())
}
/// Decode the payload of a JWT (base64url, no signature verification).
fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
use base64::Engine;
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return None;
}
let payload = parts[1];
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
serde_json::from_slice(&bytes).ok()
}
async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
let client_id = oidc_setting(&db, "oidc_client_id").await?;
let site_domain = oidc_setting(&db, "site_domain").await?;
if issuer_url.trim().is_empty() || client_id.trim().is_empty() {
return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code()))
.into_response();
}
let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await {
Some(ep) => ep,
None => {
return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code()))
.into_response();
}
};
let state = rand_token();
let redirect_uri = format!(
"{}/admin/oidc/callback",
site_domain.trim_end_matches('/')
);
let redirect_url = format!(
"{}?response_type=code&client_id={}&redirect_uri={}&scope=openid+profile&state={}",
authorization_endpoint,
urlencoding::encode(&client_id),
urlencoding::encode(&redirect_uri),
urlencoding::encode(&state),
);
let state_cookie = format!(
"oidc_state={}; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=600",
state,
);
Redirect::new(redirect_url)
.into_response()?
.with_header("set-cookie", state_cookie)
.into_response()
}
async fn oidc_callback(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let fail = |code: &str| format!("/admin/login?lang={}&error={}", lang.code(), code);
// Read saved state from cookie
let saved_state = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|part| {
let part = part.trim();
part.strip_prefix("oidc_state=").map(|v| v.to_string())
})
})
.unwrap_or_default();
// Extract code and state from query string
let query_str = request.uri().query().unwrap_or("");
let mut code = String::new();
let mut state = String::new();
for pair in query_str.split('&') {
if let Some(v) = pair.strip_prefix("code=") {
code = v.to_string();
} else if let Some(v) = pair.strip_prefix("state=") {
state = v.to_string();
}
}
if code.is_empty() || state.is_empty() || state != saved_state {
tracing::warn!(
"OIDC state mismatch: state={state:?}, saved={saved_state:?}, code_empty={}, state_empty={}",
code.is_empty(),
state.is_empty(),
);
return Redirect::new(fail("sso")).into_response();
}
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
let client_id = oidc_setting(&db, "oidc_client_id").await?;
let client_secret = oidc_setting(&db, "oidc_client_secret").await?;
let site_domain = oidc_setting(&db, "site_domain").await?;
// Get token endpoint from discovery
let token_endpoint = match oidc_discover(&issuer_url, "token_endpoint").await {
Some(ep) => ep,
None => {
tracing::warn!("OIDC discovery failed for issuer_url={issuer_url:?}");
return Redirect::new(fail("sso_provider")).into_response();
}
};
let redirect_uri = format!(
"{}/admin/oidc/callback",
site_domain.trim_end_matches('/')
);
// Exchange code for tokens
let token_resp = reqwest::Client::new()
.post(&token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", &code),
("redirect_uri", &redirect_uri),
("client_id", &client_id),
("client_secret", &client_secret),
])
.send()
.await;
let token_json: serde_json::Value = match token_resp {
Ok(resp) => match resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::warn!("OIDC token response parse error: {e}");
return Redirect::new(fail("sso_provider")).into_response();
}
},
Err(e) => {
tracing::warn!("OIDC token request failed: {e}");
return Redirect::new(fail("sso_provider")).into_response();
}
};
let id_token = match token_json.get("id_token").and_then(|v| v.as_str()) {
Some(t) => t,
None => {
tracing::warn!("OIDC no id_token in response: {token_json}");
return Redirect::new(fail("sso_provider")).into_response();
}
};
// Decode JWT payload (no signature verification — token obtained directly from provider over TLS)
let claims = match decode_jwt_payload(id_token) {
Some(c) => c,
None => {
tracing::warn!("OIDC JWT decode failed");
return Redirect::new(fail("sso_provider")).into_response();
}
};
let preferred_username = match claims.get("preferred_username").and_then(|v| v.as_str()) {
Some(u) => u.to_string(),
None => {
tracing::warn!("OIDC no preferred_username in claims: {claims}");
return Redirect::new(fail("sso")).into_response();
}
};
let display_name = claims
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Check group membership
let allowed_groups = oidc_setting(&db, "oidc_allowed_groups").await?;
if !allowed_groups.trim().is_empty() {
let required: Vec<&str> = allowed_groups.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
let user_groups: Vec<String> = claims
.get("groups")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|g| g.as_str())
.map(|g| g.trim_start_matches('/').to_string())
.collect()
})
.unwrap_or_default();
let has_group = required.iter().any(|r| {
user_groups.iter().any(|ug| ug.eq_ignore_ascii_case(r))
});
if !has_group {
tracing::warn!(
"OIDC group check failed: user={preferred_username}, user_groups={user_groups:?}, required={required:?}"
);
return Redirect::new(fail("sso_group")).into_response();
}
}
// Find or create user
let login = preferred_username.clone();
let user = query!(User, $login == login).get(&db).await?;
let user = match user {
Some(u) => u,
None => {
let mut new_user = User {
id: Auto::auto(),
login: preferred_username.clone(),
password_hash: String::new(),
display_name: display_name.clone(),
telegram_chat_id: None,
telegram_notifications: Some(false),
status: "active".to_string(),
created_at: now_utc(),
updated_at: now_utc(),
};
new_user.save(&db).await?;
new_user
}
};
if user.status != "active" {
return Redirect::new(fail("sso_disabled")).into_response();
}
let display = user
.display_name
.as_deref()
.unwrap_or(&user.login)
.to_string();
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
session.insert(SESSION_USER_NAME, display).await?;
// Clear the oidc_state cookie
let clear_cookie = "oidc_state=; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
Redirect::new(format!("/admin/?lang={}", lang.code()))
.into_response()?
.with_header("set-cookie", clear_cookie)
.into_response()
}
// ---------------------------------------------------------------------------
// GET Handlers (protected)
// ---------------------------------------------------------------------------
@@ -719,12 +1066,24 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
Err(resp) => return Ok(resp),
};
let settings = Setting::objects().all(&db).await?;
let auth_password_checked = settings
.iter()
.find(|s| s.key == "auth_password_enabled")
.map(|s| s.value == "true")
.unwrap_or(true);
let auth_sso_checked = settings
.iter()
.find(|s| s.key == "auth_sso_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let body = SettingsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
settings,
saved: false,
auth_password_checked,
auth_sso_checked,
}
.render()?;
html_response(body, lang)
@@ -798,6 +1157,16 @@ struct SettingsForm {
timezone: String,
site_domain: String,
seo_keywords: String,
turnstile_site_key: String,
turnstile_secret_key: String,
oidc_issuer_url: String,
oidc_client_id: String,
oidc_client_secret: String,
oidc_allowed_groups: String,
#[serde(default)]
auth_password_enabled: Option<String>,
#[serde(default)]
auth_sso_enabled: Option<String>,
}
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
@@ -814,6 +1183,28 @@ 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),
("oidc_issuer_url", form.oidc_issuer_url),
("oidc_client_id", form.oidc_client_id),
("oidc_client_secret", form.oidc_client_secret),
("oidc_allowed_groups", form.oidc_allowed_groups),
(
"auth_password_enabled",
if form.auth_password_enabled.is_some() {
"true".to_string()
} else {
"false".to_string()
},
),
(
"auth_sso_enabled",
if form.auth_sso_enabled.is_some() {
"true".to_string()
} else {
"false".to_string()
},
),
] {
let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?;
@@ -836,12 +1227,24 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
}
let settings = Setting::objects().all(&db).await?;
let auth_password_checked = settings
.iter()
.find(|s| s.key == "auth_password_enabled")
.map(|s| s.value == "true")
.unwrap_or(true);
let auth_sso_checked = settings
.iter()
.find(|s| s.key == "auth_sso_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let rendered = SettingsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
settings,
saved: true,
auth_password_checked,
auth_sso_checked,
}
.render()?;
html_response(rendered, lang)
@@ -2032,6 +2435,8 @@ pub fn admin_router() -> Router {
Route::with_handler_and_name("/logout", logout, "admin-logout"),
Route::with_handler_and_name("/setup", setup_page, "admin-setup"),
Route::with_handler_and_name("/setup/submit", setup_submit, "admin-setup-submit"),
Route::with_handler_and_name("/oidc/start", oidc_start, "admin-oidc-start"),
Route::with_handler_and_name("/oidc/callback", oidc_callback, "admin-oidc-callback"),
// Protected
Route::with_handler_and_name("", admin_index, "admin-index-bare"),
Route::with_handler_and_name("/", admin_index, "admin-index"),
+54
View File
@@ -135,6 +135,19 @@ 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 settings_oidc_issuer_url: &'static str,
pub settings_oidc_client_id: &'static str,
pub settings_oidc_client_secret: &'static str,
pub settings_oidc_allowed_groups: &'static str,
pub settings_auth_password_enabled: &'static str,
pub settings_auth_sso_enabled: &'static str,
pub settings_section_advanced: &'static str,
pub settings_section_notifications: &'static str,
pub settings_section_captcha: &'static str,
pub settings_section_oidc: &'static str,
pub settings_section_general: &'static str,
pub landing_contact_label: &'static str,
pub landing_pricing_title: &'static str,
@@ -149,6 +162,11 @@ pub struct Translations {
pub login_title: &'static str,
pub login_button: &'static str,
pub login_error: &'static str,
pub login_sso_button: &'static str,
pub login_sso_error: &'static str,
pub login_sso_error_group: &'static str,
pub login_sso_error_provider: &'static str,
pub login_sso_error_user_disabled: &'static str,
pub logout: &'static str,
pub setup_title: &'static str,
pub setup_description: &'static str,
@@ -348,6 +366,19 @@ 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 (секретный ключ)",
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret",
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
settings_auth_password_enabled: "Вход по логину и паролю",
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
settings_section_advanced: "Расширенные настройки",
settings_section_notifications: "Уведомления",
settings_section_captcha: "Защита от ботов",
settings_section_oidc: "Единый вход (SSO / OIDC)",
settings_section_general: "Сайт",
landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость",
@@ -382,6 +413,11 @@ static RU: Translations = Translations {
login_title: "Вход в систему",
login_button: "Войти",
login_error: "Неверный логин или пароль.",
login_sso_button: "Войти через SSO",
login_sso_error: "Ошибка SSO-авторизации.",
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
logout: "Выйти",
setup_title: "Создание администратора",
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
@@ -551,6 +587,19 @@ 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",
settings_oidc_issuer_url: "OIDC — Issuer URL",
settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret",
settings_oidc_allowed_groups: "OIDC — Allowed groups (comma-separated, empty = all)",
settings_auth_password_enabled: "Password login",
settings_auth_sso_enabled: "SSO login (OIDC)",
settings_section_advanced: "Advanced settings",
settings_section_notifications: "Notifications",
settings_section_captcha: "Bot protection",
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
settings_section_general: "Site",
landing_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing",
@@ -585,6 +634,11 @@ static EN: Translations = Translations {
login_title: "Sign In",
login_button: "Sign In",
login_error: "Invalid login or password.",
login_sso_button: "Sign in with SSO",
login_sso_error: "SSO authentication failed.",
login_sso_error_group: "Access denied: you are not a member of an allowed group.",
login_sso_error_provider: "Could not reach the authentication provider.",
login_sso_error_user_disabled: "Your account is disabled.",
logout: "Sign Out",
setup_title: "Create Administrator",
setup_description: "There are no administrators yet. Create the first one to get started.",
+4 -2
View File
@@ -4,14 +4,15 @@ mod migrations;
pub mod models;
mod public;
mod telegram;
mod turnstile;
mod tz;
use tracing_subscriber;
use cot::cli::CliMetadata;
use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
SessionStoreTypeConfig,
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
SessionStoreConfig, SessionStoreTypeConfig,
};
use cot::db::migrations::SyncDynMigration;
use cot::middleware::SessionMiddleware;
@@ -68,6 +69,7 @@ impl Project for PettingProject {
.session(
SessionMiddlewareConfig::builder()
.secure(false)
.same_site(SameSite::Lax)
.store(
SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database)
+23
View File
@@ -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);
+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,
})
}
+12
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; }
@@ -32,6 +35,11 @@
{% if let Some(err) = error.as_ref() %}
<div class="notification is-danger is-light">{{ err }}</div>
{% endif %}
{% if auth_sso_enabled %}
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
{% endif %}
{% if auth_password_enabled %}
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
<form method="post" action="/admin/login/submit">
<div class="field">
<label class="label">{{ t.users_login }}</label>
@@ -41,8 +49,12 @@
<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>
{% endif %}
</div>
</div>
</body>
+85 -20
View File
@@ -14,12 +14,8 @@
<div class="form-card">
<form method="post" action="/admin/settings/save">
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label>
<div class="control">
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
<div class="field">
<label class="label">{{ t.settings_contact_info }}</label>
<div class="control">
@@ -32,18 +28,6 @@
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_site_domain }}</label>
<div class="control">
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_timezone }}</label>
<div class="control">
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_seo_keywords }}</label>
<div class="control">
@@ -52,10 +36,91 @@
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
</div>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
<details style="margin-top:1.5rem;">
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
{{ t.settings_section_advanced }}
</summary>
<div style="margin-top:1rem;">
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
<div class="field">
<label class="label">{{ t.settings_site_domain }}</label>
<div class="control">
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_timezone }}</label>
<div class="control">
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label>
<div class="control">
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
<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>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
<div class="field">
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
<div class="control">
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_id }}</label>
<div class="control">
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_secret }}</label>
<div class="control">
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
<div class="control">
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
{{ t.settings_auth_password_enabled }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
{{ t.settings_auth_sso_enabled }}
</label>
</div>
</div>
</details>
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</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" 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 %}
+7
View File
@@ -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() %}