998 lines
33 KiB
Rust
998 lines
33 KiB
Rust
use chrono::Datelike;
|
|
use cot::Template;
|
|
use cot::db::{Auto, Database, ForeignKey, Model};
|
|
use cot::html::Html;
|
|
use cot::request::Request;
|
|
use cot::request::extractors::Path;
|
|
use cot::response::{IntoResponse, Redirect, Response};
|
|
use cot::router::{Route, Router};
|
|
use serde::Deserialize;
|
|
use tracing::info;
|
|
|
|
use cot::db::query;
|
|
|
|
use crate::i18n::{Lang, Translations};
|
|
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
|
|
use crate::telegram;
|
|
|
|
fn detect_lang(request: &Request) -> Lang {
|
|
if let Some(q) = request.uri().query() {
|
|
for pair in q.split('&') {
|
|
if let Some(code) = pair.strip_prefix("lang=") {
|
|
if let Some(lang) = Lang::from_code(code) {
|
|
return lang;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(cookie) = request
|
|
.headers()
|
|
.get("cookie")
|
|
.and_then(|v| v.to_str().ok())
|
|
{
|
|
for part in cookie.split(';') {
|
|
let part = part.trim();
|
|
if let Some(code) = part.strip_prefix("lang=") {
|
|
if let Some(lang) = Lang::from_code(code.trim()) {
|
|
return lang;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
request
|
|
.headers()
|
|
.get("accept-language")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(Lang::from_accept_language)
|
|
.unwrap_or(Lang::Ru)
|
|
}
|
|
|
|
fn lang_cookie(lang: Lang) -> String {
|
|
format!(
|
|
"lang={}; Path=/; SameSite=Lax; Max-Age=31536000",
|
|
lang.code()
|
|
)
|
|
}
|
|
|
|
fn html_response(body: String, lang: Lang) -> cot::Result<Response> {
|
|
Html::new(body)
|
|
.with_header("set-cookie", lang_cookie(lang))
|
|
.into_response()
|
|
}
|
|
|
|
fn now_utc() -> chrono::NaiveDateTime {
|
|
chrono::Utc::now().naive_utc()
|
|
}
|
|
|
|
#[derive(Debug, Template)]
|
|
#[template(path = "landing.html")]
|
|
struct LandingTemplate<'a> {
|
|
t: &'a Translations,
|
|
lang: Lang,
|
|
contact_info: String,
|
|
pricing_info: String,
|
|
seo_keywords: String,
|
|
testimonials: Vec<Testimonial>,
|
|
site_domain: String,
|
|
review_count: usize,
|
|
turnstile_site_key: String,
|
|
}
|
|
|
|
#[derive(Debug, Template)]
|
|
#[template(path = "thank_you.html")]
|
|
struct ThankYouTemplate<'a> {
|
|
t: &'a Translations,
|
|
lang: Lang,
|
|
}
|
|
|
|
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
|
let lang = detect_lang(&request);
|
|
|
|
let ua = request
|
|
.headers()
|
|
.get("user-agent")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("-");
|
|
let referer = request
|
|
.headers()
|
|
.get("referer")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("-");
|
|
let ip = request
|
|
.headers()
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.split(',').next())
|
|
.map(|s| s.trim())
|
|
.unwrap_or("-");
|
|
|
|
info!(
|
|
target: "landing",
|
|
ip = ip,
|
|
lang = lang.code(),
|
|
referer = referer,
|
|
ua = ua,
|
|
"landing visit"
|
|
);
|
|
let key = "contact_info".to_string();
|
|
let contact_info = query!(Setting, $key == key)
|
|
.get(&db)
|
|
.await?
|
|
.map(|s| s.value)
|
|
.unwrap_or_default();
|
|
let pricing_key = "pricing_info".to_string();
|
|
let pricing_info = query!(Setting, $key == pricing_key)
|
|
.get(&db)
|
|
.await?
|
|
.map(|s| s.value)
|
|
.unwrap_or_default();
|
|
let domain_key = "site_domain".to_string();
|
|
let site_domain = query!(Setting, $key == domain_key)
|
|
.get(&db)
|
|
.await?
|
|
.map(|s| s.value)
|
|
.unwrap_or_else(|| "https://example.net".to_string());
|
|
let seo_key = "seo_keywords".to_string();
|
|
let seo_keywords = query!(Setting, $key == seo_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));
|
|
let review_count = testimonials.len();
|
|
let body = LandingTemplate {
|
|
t: lang.t(),
|
|
lang,
|
|
contact_info,
|
|
pricing_info,
|
|
seo_keywords,
|
|
testimonials,
|
|
site_domain,
|
|
review_count,
|
|
turnstile_site_key,
|
|
}
|
|
.render()?;
|
|
html_response(body, lang)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
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> {
|
|
let lang = detect_lang(&request);
|
|
let body = request.into_body();
|
|
let bytes = body.into_bytes().await?;
|
|
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,
|
|
phone: form.phone.filter(|s| !s.trim().is_empty()),
|
|
email: None,
|
|
comment: form.comment.filter(|s| !s.trim().is_empty()),
|
|
status: "new".to_string(),
|
|
client_id: None,
|
|
created_at: now_utc(),
|
|
updated_at: now_utc(),
|
|
};
|
|
lead.save(&db).await?;
|
|
|
|
telegram::notify_new_lead(
|
|
&db,
|
|
&lead.name,
|
|
lead.phone.as_deref(),
|
|
lead.comment.as_deref(),
|
|
)
|
|
.await;
|
|
|
|
let rendered = ThankYouTemplate { t: lang.t(), lang }.render()?;
|
|
html_response(rendered, lang)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client Portal
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug)]
|
|
struct PortalVisit {
|
|
visit: Visit,
|
|
admin_name: String,
|
|
media: Vec<Media>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CalendarDay {
|
|
number: u32,
|
|
class_name: &'static str,
|
|
href: Option<String>,
|
|
title: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CalendarMonth {
|
|
label: String,
|
|
leading_blanks: Vec<u8>,
|
|
days: Vec<CalendarDay>,
|
|
}
|
|
|
|
#[derive(Debug, Template)]
|
|
#[template(path = "client_portal.html")]
|
|
struct ClientPortalTemplate<'a> {
|
|
t: &'a Translations,
|
|
lang: Lang,
|
|
client: Client,
|
|
upcoming: Vec<PortalVisit>,
|
|
past: Vec<PortalVisit>,
|
|
feedback_sent: bool,
|
|
turnstile_site_key: String,
|
|
notifications_enabled: bool,
|
|
vapid_public_key: String,
|
|
calendar_months: Vec<CalendarMonth>,
|
|
page: usize,
|
|
total_pages: usize,
|
|
has_previous_page: bool,
|
|
has_next_page: bool,
|
|
}
|
|
|
|
const PORTAL_VISITS_PER_PAGE: usize = 10;
|
|
|
|
fn query_page(request: &Request) -> usize {
|
|
request
|
|
.uri()
|
|
.query()
|
|
.and_then(|query| {
|
|
query.split('&').find_map(|part| {
|
|
part.strip_prefix("page=")
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
.filter(|page| *page > 0)
|
|
})
|
|
})
|
|
.unwrap_or(1)
|
|
}
|
|
|
|
fn month_label(year: i32, month: u32, lang: Lang) -> String {
|
|
const RU: [&str; 12] = [
|
|
"Январь",
|
|
"Февраль",
|
|
"Март",
|
|
"Апрель",
|
|
"Май",
|
|
"Июнь",
|
|
"Июль",
|
|
"Август",
|
|
"Сентябрь",
|
|
"Октябрь",
|
|
"Ноябрь",
|
|
"Декабрь",
|
|
];
|
|
const EN: [&str; 12] = [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
];
|
|
let names = if lang == Lang::Ru { RU } else { EN };
|
|
format!("{} {year}", names[(month - 1) as usize])
|
|
}
|
|
|
|
async fn client_portal(
|
|
request: Request,
|
|
db: Database,
|
|
Path(token): Path<String>,
|
|
) -> cot::Result<Response> {
|
|
let lang = detect_lang(&request);
|
|
let feedback_sent = request
|
|
.uri()
|
|
.query()
|
|
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
|
|
.unwrap_or(false);
|
|
let requested_page = query_page(&request);
|
|
|
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
|
Some(c) if c.status != "deleted" => c,
|
|
Some(_) => return Html::new("404").into_response(),
|
|
None => return Html::new("404").into_response(),
|
|
};
|
|
|
|
let client_id = client.id.unwrap();
|
|
let tz = crate::tz::load_tz(&db).await;
|
|
let today = crate::tz::today_in_tz(tz);
|
|
|
|
let mut visits = Visit::objects().all(&db).await?;
|
|
visits.retain(|v| {
|
|
v.client_id.primary_key().unwrap() == client_id
|
|
&& v.status != "cancelled"
|
|
&& v.status != "deleted"
|
|
});
|
|
visits.sort_by(|a, b| {
|
|
a.visit_date
|
|
.cmp(&b.visit_date)
|
|
.then(a.time_start.cmp(&b.time_start))
|
|
});
|
|
|
|
let users = User::objects().all(&db).await?;
|
|
let all_media = Media::objects().all(&db).await?;
|
|
|
|
let build_portal_visit = |v: Visit| -> PortalVisit {
|
|
let uid: i64 = v.user_id.primary_key().unwrap();
|
|
let admin_name = users
|
|
.iter()
|
|
.find(|u| u.id.unwrap() == uid)
|
|
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
|
|
.unwrap_or_default();
|
|
let vid = v.id.unwrap();
|
|
let media: Vec<Media> = all_media
|
|
.iter()
|
|
.filter(|m| {
|
|
m.status == "active"
|
|
&& m.client_id.primary_key().unwrap() == client_id
|
|
&& m.visit_id
|
|
.as_ref()
|
|
.map(|fk| fk.primary_key().unwrap() == vid)
|
|
.unwrap_or(false)
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
PortalVisit {
|
|
visit: v,
|
|
admin_name,
|
|
media,
|
|
}
|
|
};
|
|
|
|
let mut upcoming_visits = Vec::new();
|
|
let mut past_visits = Vec::new();
|
|
for v in visits {
|
|
if v.visit_date >= today && v.status == "scheduled" {
|
|
upcoming_visits.push(v);
|
|
} else {
|
|
past_visits.push(v);
|
|
}
|
|
}
|
|
past_visits.reverse(); // newest first
|
|
|
|
let total_pages = past_visits.len().div_ceil(PORTAL_VISITS_PER_PAGE).max(1);
|
|
let page = requested_page.min(total_pages);
|
|
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
|
|
let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len());
|
|
let past = past_visits[page_start..page_end]
|
|
.iter()
|
|
.cloned()
|
|
.map(&build_portal_visit)
|
|
.collect();
|
|
let upcoming: Vec<_> = upcoming_visits
|
|
.iter()
|
|
.cloned()
|
|
.map(&build_portal_visit)
|
|
.collect();
|
|
|
|
let mut month_keys: Vec<(i32, u32)> = past_visits
|
|
.iter()
|
|
.chain(upcoming_visits.iter())
|
|
.map(|visit| (visit.visit_date.year(), visit.visit_date.month()))
|
|
.collect();
|
|
month_keys.sort();
|
|
month_keys.dedup();
|
|
month_keys.reverse();
|
|
let calendar_months = month_keys
|
|
.into_iter()
|
|
.map(|(year, month)| {
|
|
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
|
let next_month = if month == 12 {
|
|
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
|
} else {
|
|
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
|
};
|
|
let days_in_month = (next_month - first).num_days() as u32;
|
|
let leading_blanks = vec![0; first.weekday().num_days_from_monday() as usize];
|
|
let days = (1..=days_in_month)
|
|
.map(|day| {
|
|
let date = chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap();
|
|
if let Some(_visit) = upcoming_visits
|
|
.iter()
|
|
.find(|visit| visit.visit_date == date)
|
|
{
|
|
CalendarDay {
|
|
number: day,
|
|
class_name: "future",
|
|
href: None,
|
|
title: lang.t().portal_future_visit.to_string(),
|
|
}
|
|
} else if let Some((index, visit)) = past_visits
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, visit)| visit.visit_date == date)
|
|
{
|
|
CalendarDay {
|
|
number: day,
|
|
class_name: "past",
|
|
href: Some(format!(
|
|
"?page={}#visit-{}",
|
|
index / PORTAL_VISITS_PER_PAGE + 1,
|
|
visit.id.unwrap()
|
|
)),
|
|
title: lang.t().visit_status(&visit.status).to_string(),
|
|
}
|
|
} else {
|
|
CalendarDay {
|
|
number: day,
|
|
class_name: "empty",
|
|
href: None,
|
|
title: String::new(),
|
|
}
|
|
}
|
|
})
|
|
.collect();
|
|
CalendarMonth {
|
|
label: month_label(year, month, lang),
|
|
leading_blanks,
|
|
days,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let notification_key = "client_notifications_enabled".to_string();
|
|
let vapid_public_key = crate::web_push::load_config(&db)
|
|
.await
|
|
.map(|config| config.public_key)
|
|
.unwrap_or_default();
|
|
let notifications_enabled = !vapid_public_key.is_empty()
|
|
&& query!(Setting, $key == notification_key)
|
|
.get(&db)
|
|
.await?
|
|
.map(|setting| setting.value == "true")
|
|
.unwrap_or(false);
|
|
|
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
|
let body = ClientPortalTemplate {
|
|
t: lang.t(),
|
|
lang,
|
|
client,
|
|
upcoming,
|
|
past,
|
|
feedback_sent,
|
|
turnstile_site_key,
|
|
notifications_enabled,
|
|
vapid_public_key,
|
|
calendar_months,
|
|
page,
|
|
total_pages,
|
|
has_previous_page: page > 1,
|
|
has_next_page: page < total_pages,
|
|
}
|
|
.render()?;
|
|
html_response(body, lang)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PushKeysForm {
|
|
p256dh: String,
|
|
auth: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PushSubscriptionForm {
|
|
endpoint: String,
|
|
keys: PushKeysForm,
|
|
#[serde(default)]
|
|
language: String,
|
|
}
|
|
|
|
async fn portal_push_subscribe(
|
|
request: Request,
|
|
db: Database,
|
|
Path(token): Path<String>,
|
|
) -> cot::Result<Response> {
|
|
if crate::web_push::load_config(&db).await.is_none() {
|
|
return Html::new("404").into_response();
|
|
}
|
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
|
Some(client) if client.status != "deleted" => client,
|
|
_ => return Html::new("404").into_response(),
|
|
};
|
|
let bytes = request.into_body().into_bytes().await?;
|
|
let form: PushSubscriptionForm =
|
|
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
|
if !form.endpoint.starts_with("https://")
|
|
|| form.endpoint.len() > 4096
|
|
|| form.keys.p256dh.len() > 512
|
|
|| form.keys.auth.len() > 256
|
|
{
|
|
return Html::new("400").into_response();
|
|
}
|
|
let endpoint = form.endpoint.clone();
|
|
let now = chrono::Utc::now().naive_utc();
|
|
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
|
.get(&db)
|
|
.await?
|
|
{
|
|
subscription.client_id = ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap()));
|
|
subscription.p256dh = form.keys.p256dh;
|
|
subscription.auth = form.keys.auth;
|
|
subscription.language = if form.language == "ru" { "ru" } else { "en" }.to_string();
|
|
subscription.status = "active".to_string();
|
|
subscription.updated_at = now;
|
|
subscription.save(&db).await?;
|
|
} else {
|
|
let mut subscription = PushSubscription {
|
|
id: Auto::auto(),
|
|
client_id: ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap())),
|
|
endpoint: form.endpoint,
|
|
p256dh: form.keys.p256dh,
|
|
auth: form.keys.auth,
|
|
language: if form.language == "ru" { "ru" } else { "en" }.to_string(),
|
|
status: "active".to_string(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
subscription.save(&db).await?;
|
|
}
|
|
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
|
response
|
|
.headers_mut()
|
|
.insert("content-type", "application/json".parse().unwrap());
|
|
Ok(response)
|
|
}
|
|
|
|
async fn portal_push_unsubscribe(
|
|
request: Request,
|
|
db: Database,
|
|
Path(token): Path<String>,
|
|
) -> cot::Result<Response> {
|
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
|
Some(client) if client.status != "deleted" => client,
|
|
_ => return Html::new("404").into_response(),
|
|
};
|
|
let bytes = request.into_body().into_bytes().await?;
|
|
let value: serde_json::Value =
|
|
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
|
let endpoint = value
|
|
.get("endpoint")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
|
.get(&db)
|
|
.await?
|
|
{
|
|
if subscription.client_id.primary_key().unwrap() == client.id.unwrap() {
|
|
subscription.status = "archived".to_string();
|
|
subscription.updated_at = chrono::Utc::now().naive_utc();
|
|
subscription.save(&db).await?;
|
|
}
|
|
}
|
|
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
|
response
|
|
.headers_mut()
|
|
.insert("content-type", "application/json".parse().unwrap());
|
|
Ok(response)
|
|
}
|
|
|
|
async fn web_manifest(_request: Request, Path(token): Path<String>) -> cot::Result<Response> {
|
|
let manifest = serde_json::json!({
|
|
"id": format!("/client/{token}"),
|
|
"name": "Pet Sitting Visits",
|
|
"short_name": "Pet Visits",
|
|
"start_url": format!("/client/{token}"),
|
|
"display": "standalone",
|
|
"background_color": "#f8f7ff",
|
|
"theme_color": "#7c6cff",
|
|
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
|
});
|
|
let mut response = Response::new(cot::Body::fixed(manifest.to_string()));
|
|
response
|
|
.headers_mut()
|
|
.insert("content-type", "application/manifest+json".parse().unwrap());
|
|
Ok(response)
|
|
}
|
|
|
|
async fn service_worker(_request: Request) -> cot::Result<Response> {
|
|
let script = r#"
|
|
self.addEventListener('push', function(event) {
|
|
var data = event.data ? event.data.json() : {};
|
|
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
|
|
body: data.body || '', tag: data.tag || 'visit', data: { url: data.url || '/' },
|
|
icon: '/favicon.svg', badge: '/favicon.svg'
|
|
}));
|
|
});
|
|
self.addEventListener('notificationclick', function(event) {
|
|
event.notification.close();
|
|
var target = new URL(event.notification.data.url || '/', self.location.origin).href;
|
|
event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(list) {
|
|
for (var i = 0; i < list.length; i++) {
|
|
if ('focus' in list[i]) { list[i].navigate(target); return list[i].focus(); }
|
|
}
|
|
return clients.openWindow ? clients.openWindow(target) : undefined;
|
|
}));
|
|
});
|
|
"#;
|
|
let mut response = Response::new(cot::Body::fixed(script));
|
|
response
|
|
.headers_mut()
|
|
.insert("content-type", "application/javascript".parse().unwrap());
|
|
response
|
|
.headers_mut()
|
|
.insert("service-worker-allowed", "/".parse().unwrap());
|
|
Ok(response)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct FeedbackForm {
|
|
feedback: String,
|
|
#[serde(default, rename = "cf-turnstile-response")]
|
|
cf_turnstile_response: Option<String>,
|
|
}
|
|
|
|
async fn submit_feedback(
|
|
request: Request,
|
|
db: Database,
|
|
Path((token, visit_id)): Path<(String, i64)>,
|
|
) -> cot::Result<Response> {
|
|
let lang = detect_lang(&request);
|
|
|
|
// Verify token matches visit's client
|
|
let token_clone = token.clone();
|
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
|
Some(c) if c.status != "deleted" => c,
|
|
Some(_) => return Html::new("404").into_response(),
|
|
None => return Html::new("404").into_response(),
|
|
};
|
|
let client_id = client.id.unwrap();
|
|
|
|
let bytes = request.into_body().into_bytes().await?;
|
|
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.status == "deleted" {
|
|
return Redirect::new(format!("/client/{}?lang={}", token_clone, lang.code()))
|
|
.into_response();
|
|
}
|
|
if visit.client_id.primary_key().unwrap() == client_id {
|
|
visit.client_feedback = Some(form.feedback);
|
|
visit.updated_at = now_utc();
|
|
visit.save(&db).await?;
|
|
}
|
|
}
|
|
|
|
Redirect::new(format!(
|
|
"/client/{}?lang={}&feedback=ok",
|
|
token_clone,
|
|
lang.code()
|
|
))
|
|
.into_response()
|
|
}
|
|
|
|
/// Serve media files for the client portal (no auth required, but only via token).
|
|
async fn portal_media(
|
|
request: Request,
|
|
db: Database,
|
|
Path((token, media_id)): Path<(String, i64)>,
|
|
) -> cot::Result<Response> {
|
|
// Verify token
|
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
|
Some(c) if c.status != "deleted" => c,
|
|
Some(_) => return Html::new("404").into_response(),
|
|
None => return Html::new("404").into_response(),
|
|
};
|
|
let client_id = client.id.unwrap();
|
|
|
|
let media = match query!(Media, $id == media_id).get(&db).await? {
|
|
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
|
|
_ => return Html::new("404").into_response(),
|
|
};
|
|
if let Some(fk) = &media.visit_id {
|
|
let visit_id: i64 = fk.primary_key().unwrap();
|
|
match query!(Visit, $id == visit_id).get(&db).await? {
|
|
Some(v) if v.status != "deleted" => {}
|
|
_ => return Html::new("404").into_response(),
|
|
}
|
|
}
|
|
|
|
let range = request
|
|
.headers()
|
|
.get("range")
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_owned);
|
|
|
|
match {
|
|
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"png" => "image/png",
|
|
"heic" | "heif" => "image/heic",
|
|
"webp" => "image/webp",
|
|
"mp4" => "video/mp4",
|
|
"mov" => "video/quicktime",
|
|
"avi" => "video/x-msvideo",
|
|
"mkv" => "video/x-matroska",
|
|
"webm" => "video/webm",
|
|
_ => "application/octet-stream",
|
|
};
|
|
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
|
} {
|
|
Ok(response) => Ok(response),
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
target: "uploads",
|
|
media_id,
|
|
db_path = %media.file_path,
|
|
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
|
error = %err,
|
|
"portal media file is missing or unreadable"
|
|
);
|
|
Html::new("404").into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn portal_media_thumbnail(
|
|
_request: Request,
|
|
db: Database,
|
|
Path((token, media_id)): Path<(String, i64)>,
|
|
) -> cot::Result<Response> {
|
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
|
Some(client) if client.status != "deleted" => client,
|
|
_ => return Html::new("404").into_response(),
|
|
};
|
|
let media = match query!(Media, $id == media_id).get(&db).await? {
|
|
Some(media)
|
|
if media.client_id.primary_key().unwrap() == client.id.unwrap()
|
|
&& media.status == "active"
|
|
&& media.file_type == "photo" =>
|
|
{
|
|
media
|
|
}
|
|
_ => return Html::new("404").into_response(),
|
|
};
|
|
if let Some(visit) = &media.visit_id {
|
|
let visit_id = visit.primary_key().unwrap();
|
|
match query!(Visit, $id == visit_id).get(&db).await? {
|
|
Some(visit) if visit.status != "deleted" => {}
|
|
_ => return Html::new("404").into_response(),
|
|
}
|
|
}
|
|
match crate::uploads::ensure_thumbnail(&media.file_path).await {
|
|
Ok(path) => {
|
|
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None)
|
|
.await
|
|
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
|
response.headers_mut().insert(
|
|
"cache-control",
|
|
"private, max-age=31536000, immutable".parse().unwrap(),
|
|
);
|
|
Ok(response)
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
|
|
crate::uploads::ranged_file_response(
|
|
&media.file_path,
|
|
crate::uploads::content_type_for_path(&media.file_path),
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(|error| cot::Error::internal(error.to_string()))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn serve_testimonial_image(
|
|
_request: Request,
|
|
db: Database,
|
|
Path(id): Path<i64>,
|
|
) -> cot::Result<Response> {
|
|
let testimonial = match query!(Testimonial, $id == id).get(&db).await? {
|
|
Some(t) => t,
|
|
None => return Html::new("404").into_response(),
|
|
};
|
|
let path = match &testimonial.image_path {
|
|
Some(p) => p.clone(),
|
|
None => return Html::new("404").into_response(),
|
|
};
|
|
match crate::uploads::read_db_file(&path).await {
|
|
Ok(data) => {
|
|
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"png" => "image/png",
|
|
"webp" => "image/webp",
|
|
"heic" | "heif" => "image/heic",
|
|
_ => "application/octet-stream",
|
|
};
|
|
let body = cot::Body::fixed(data);
|
|
let mut resp = Response::new(body);
|
|
resp.headers_mut()
|
|
.insert("content-type", content_type.parse().unwrap());
|
|
resp.headers_mut()
|
|
.insert("cache-control", "public, max-age=86400".parse().unwrap());
|
|
Ok(resp)
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
target: "uploads",
|
|
testimonial_id = id,
|
|
db_path = %path,
|
|
resolved_path = %crate::uploads::resolved_display_path(&path),
|
|
error = %err,
|
|
"testimonial image is missing or unreadable"
|
|
);
|
|
Html::new("404").into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn favicon(_request: Request) -> cot::Result<Response> {
|
|
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
<ellipse cx="32" cy="42" rx="14" ry="16" fill="#7c6cff"/>
|
|
<ellipse cx="14" cy="20" rx="7" ry="9" fill="#7c6cff" transform="rotate(-10 14 20)"/>
|
|
<ellipse cx="50" cy="20" rx="7" ry="9" fill="#7c6cff" transform="rotate(10 50 20)"/>
|
|
<ellipse cx="23" cy="8" rx="5.5" ry="7" fill="#7c6cff" transform="rotate(-5 23 8)"/>
|
|
<ellipse cx="41" cy="8" rx="5.5" ry="7" fill="#7c6cff" transform="rotate(5 41 8)"/>
|
|
</svg>"##;
|
|
let mut resp = Response::new(cot::Body::fixed(svg.as_bytes().to_vec()));
|
|
resp.headers_mut()
|
|
.insert("content-type", "image/svg+xml".parse().unwrap());
|
|
resp.headers_mut()
|
|
.insert("cache-control", "public, max-age=604800".parse().unwrap());
|
|
Ok(resp)
|
|
}
|
|
|
|
async fn serve_static(_request: Request, Path(filename): Path<String>) -> cot::Result<Response> {
|
|
// Only allow simple filenames (no path traversal)
|
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
|
return Html::new("404").into_response();
|
|
}
|
|
let ext = filename.rsplit('.').next().unwrap_or("");
|
|
// Try relative path first, then /app/static/ (Docker)
|
|
let path = format!("static/{filename}");
|
|
let data = match tokio::fs::read(&path).await {
|
|
Ok(d) => d,
|
|
Err(_) => match tokio::fs::read(format!("/app/static/{filename}")).await {
|
|
Ok(d) => d,
|
|
Err(_) => return Html::new("404").into_response(),
|
|
},
|
|
};
|
|
let content_type = match ext {
|
|
"png" => "image/png",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"webp" => "image/webp",
|
|
"svg" => "image/svg+xml",
|
|
"gif" => "image/gif",
|
|
_ => "application/octet-stream",
|
|
};
|
|
let body = cot::Body::fixed(data);
|
|
let mut resp = Response::new(body);
|
|
resp.headers_mut()
|
|
.insert("content-type", content_type.parse().unwrap());
|
|
resp.headers_mut()
|
|
.insert("cache-control", "public, max-age=604800".parse().unwrap());
|
|
Ok(resp)
|
|
}
|
|
|
|
async fn robots_txt(_request: Request, db: Database) -> cot::Result<Response> {
|
|
let domain_key = "site_domain".to_string();
|
|
let site_domain = query!(Setting, $key == domain_key)
|
|
.get(&db)
|
|
.await?
|
|
.map(|s| s.value)
|
|
.unwrap_or_else(|| "https://example.net".to_string());
|
|
let body = format!(
|
|
"User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /client/\nSitemap: {}/sitemap.xml\n",
|
|
site_domain
|
|
);
|
|
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
|
resp.headers_mut()
|
|
.insert("content-type", "text/plain; charset=utf-8".parse().unwrap());
|
|
Ok(resp)
|
|
}
|
|
|
|
async fn sitemap_xml(_request: Request, db: Database) -> cot::Result<Response> {
|
|
let domain_key = "site_domain".to_string();
|
|
let site_domain = query!(Setting, $key == domain_key)
|
|
.get(&db)
|
|
.await?
|
|
.map(|s| s.value)
|
|
.unwrap_or_else(|| "https://example.net".to_string());
|
|
let body = format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
<url>
|
|
<loc>{domain}/?lang=ru</loc>
|
|
<xhtml:link rel="alternate" hreflang="ru" href="{domain}/?lang=ru"/>
|
|
<xhtml:link rel="alternate" hreflang="en" href="{domain}/?lang=en"/>
|
|
<xhtml:link rel="alternate" hreflang="x-default" href="{domain}/"/>
|
|
</url>
|
|
<url>
|
|
<loc>{domain}/?lang=en</loc>
|
|
<xhtml:link rel="alternate" hreflang="ru" href="{domain}/?lang=ru"/>
|
|
<xhtml:link rel="alternate" hreflang="en" href="{domain}/?lang=en"/>
|
|
<xhtml:link rel="alternate" hreflang="x-default" href="{domain}/"/>
|
|
</url>
|
|
</urlset>
|
|
"#,
|
|
domain = site_domain
|
|
);
|
|
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
|
resp.headers_mut().insert(
|
|
"content-type",
|
|
"application/xml; charset=utf-8".parse().unwrap(),
|
|
);
|
|
Ok(resp)
|
|
}
|
|
|
|
pub fn public_router() -> Router {
|
|
Router::with_urls([
|
|
Route::with_handler_and_name("/", landing_page, "landing"),
|
|
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
|
|
Route::with_handler_and_name(
|
|
"/client/{token}/manifest.webmanifest",
|
|
web_manifest,
|
|
"web-manifest",
|
|
),
|
|
Route::with_handler_and_name("/service-worker.js", service_worker, "service-worker"),
|
|
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
|
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
|
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
|
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
|
Route::with_handler_and_name(
|
|
"/testimonial-image/{id}",
|
|
serve_testimonial_image,
|
|
"testimonial-image",
|
|
),
|
|
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
|
|
Route::with_handler_and_name(
|
|
"/client/{token}/push/subscribe",
|
|
portal_push_subscribe,
|
|
"client-push-subscribe",
|
|
),
|
|
Route::with_handler_and_name(
|
|
"/client/{token}/push/unsubscribe",
|
|
portal_push_unsubscribe,
|
|
"client-push-unsubscribe",
|
|
),
|
|
Route::with_handler_and_name(
|
|
"/client/{token}/{visit_id}/feedback",
|
|
submit_feedback,
|
|
"client-feedback",
|
|
),
|
|
Route::with_handler_and_name(
|
|
"/client/{token}/media/{media_id}",
|
|
portal_media,
|
|
"client-media",
|
|
),
|
|
Route::with_handler_and_name(
|
|
"/client/{token}/media/{media_id}/thumbnail",
|
|
portal_media_thumbnail,
|
|
"client-media-thumbnail",
|
|
),
|
|
])
|
|
}
|