Files
web-petting/src/public.rs
T

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",
),
])
}