diff --git a/Cargo.toml b/Cargo.toml index 85b6ca2..a89446b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "web-petting" -version = "0.1.6" +version = "0.1.7" edition = "2024" [dependencies] diff --git a/src/admin.rs b/src/admin.rs index 02a5cbf..680a153 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -792,6 +792,7 @@ struct SettingsForm { contact_info: String, pricing_info: String, timezone: String, + site_domain: String, } async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result { @@ -807,6 +808,7 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: ("contact_info", form.contact_info), ("pricing_info", form.pricing_info), ("timezone", form.timezone), + ("site_domain", form.site_domain), ] { let k = key.to_string(); let existing = query!(Setting, $key == k).get(&db).await?; diff --git a/src/i18n.rs b/src/i18n.rs index 19b00ce..319afe2 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -131,6 +131,7 @@ pub struct Translations { pub settings_contact_info: &'static str, pub settings_pricing_info: &'static str, pub settings_timezone: &'static str, + pub settings_site_domain: &'static str, pub landing_contact_label: &'static str, pub landing_pricing_title: &'static str, @@ -340,6 +341,7 @@ static RU: Translations = Translations { settings_contact_info: "Контактная информация (отображается на лендинге)", settings_pricing_info: "Блок с ценами (отображается на лендинге)", settings_timezone: "Часовой пояс (например Asia/Vladivostok)", + settings_site_domain: "Домен сайта (например https://example.com)", landing_contact_label: "Или свяжитесь с нами напрямую", landing_pricing_title: "Стоимость", @@ -539,6 +541,7 @@ static EN: Translations = Translations { settings_contact_info: "Contact info (shown on landing page)", settings_pricing_info: "Pricing block (shown on landing page)", settings_timezone: "Timezone (e.g. Asia/Vladivostok)", + settings_site_domain: "Site domain (e.g. https://example.com)", landing_contact_label: "Or contact us directly", landing_pricing_title: "Pricing", diff --git a/src/public.rs b/src/public.rs index d0afeed..ca06acb 100644 --- a/src/public.rs +++ b/src/public.rs @@ -72,6 +72,8 @@ struct LandingTemplate<'a> { contact_info: String, pricing_info: String, testimonials: Vec, + site_domain: String, + review_count: usize, } #[derive(Debug, Template)] @@ -95,15 +97,24 @@ async fn landing_page(request: Request, db: Database) -> cot::Result { .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 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, testimonials, + site_domain, + review_count, } .render()?; html_response(body, lang) @@ -367,9 +378,108 @@ async fn serve_testimonial_image( } } +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 { + let safe_name = filename.replace(['/', '\\', '.', ' '], ""); + // rebuild with original extension + let ext = filename.rsplit('.').next().unwrap_or(""); + let _stem = filename.rsplit('.').last().unwrap_or(""); + let _ = safe_name; // just for validation idea; use the path directly with whitelist + let path = format!("static/{}", filename); + match tokio::fs::read(&path).await { + Ok(data) => { + 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) + } + Err(_) => Html::new("404").into_response(), + } +} + +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("/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}", diff --git a/static/cat_bottom_left.png b/static/cat_bottom_left.png new file mode 100644 index 0000000..24e3cca Binary files /dev/null and b/static/cat_bottom_left.png differ diff --git a/static/cat_up_right.png b/static/cat_up_right.png new file mode 100644 index 0000000..9f89c0b Binary files /dev/null and b/static/cat_up_right.png differ diff --git a/templates/admin/layout.html b/templates/admin/layout.html index 1a1b2db..51fdb25 100644 --- a/templates/admin/layout.html +++ b/templates/admin/layout.html @@ -4,6 +4,7 @@ {{ t.nav_title }} — {% block title %}{% endblock %} + diff --git a/templates/admin/settings.html b/templates/admin/settings.html index e8e0ac1..11ce647 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -38,6 +38,12 @@ +
+ +
+ +
+
diff --git a/templates/admin/setup.html b/templates/admin/setup.html index cb7b147..76abf54 100644 --- a/templates/admin/setup.html +++ b/templates/admin/setup.html @@ -4,11 +4,18 @@ {{ t.nav_title }} — {{ t.setup_title }} + diff --git a/templates/client_portal.html b/templates/client_portal.html index 621c49e..43a91d8 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -4,7 +4,9 @@ {{ t.portal_title }} — {{ client.name }} +