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 { 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, 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 { 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, comment: Option, #[serde(default, rename = "cf-turnstile-response")] cf_turnstile_response: Option, } async fn submit_lead(request: Request, db: Database) -> cot::Result { 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, } #[derive(Debug)] struct CalendarDay { number: u32, class_name: &'static str, href: Option, title: String, } #[derive(Debug)] struct CalendarMonth { label: String, leading_blanks: Vec, days: Vec, } #[derive(Debug, Template)] #[template(path = "client_portal.html")] struct ClientPortalTemplate<'a> { t: &'a Translations, lang: Lang, client: Client, upcoming: Vec, past: Vec, feedback_sent: bool, turnstile_site_key: String, notifications_enabled: bool, vapid_public_key: String, calendar_months: Vec, 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::().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, ) -> cot::Result { 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 = 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, ) -> cot::Result { 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, ) -> cot::Result { 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) -> cot::Result { 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 { 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, } async fn submit_feedback( request: Request, db: Database, Path((token, visit_id)): Path<(String, i64)>, ) -> cot::Result { 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 { // 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 { 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, ) -> cot::Result { 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 { let svg = r##" "##; 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) -> cot::Result { // 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 { 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 { 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#" {domain}/?lang=ru {domain}/?lang=en "#, 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", ), ]) }