use std::sync::Arc; use axum::body::{to_bytes, Body}; use axum::extract::Request; use axum::http::{header, StatusCode}; use axum::middleware::Next; use axum::response::Response; use tracing::warn; use crate::language::{ language_from_accept_language, query_string_with_language, supported_languages, }; use crate::state::AppState; const OG_PLACEHOLDER: &str = r#""#; const BUGSINK_CONFIG_PLACEHOLDER: &str = "__PERFECT_POSTCODE_BUGSINK_CONFIG__"; const HTML_BODY_LIMIT: usize = 5 * 1024 * 1024; struct SeoPage { canonical_path: &'static str, title: &'static str, description: &'static str, indexable: bool, } /// Escape a string for safe inclusion inside a double-quoted HTML attribute value. fn escape_attr(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), _ => out.push(c), } } out } fn trim_trailing_slash(path: &str) -> &str { if path.len() > 1 { path.trim_end_matches('/') } else { path } } fn seo_page_for_path(path: &str) -> Option { let path = trim_trailing_slash(path); match path { "/" => Some(SeoPage { canonical_path: "/", title: "Stop searching the wrong places | Perfect Postcode", description: "Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings.", indexable: true, }), "/learn" | "/support" => Some(SeoPage { canonical_path: "/learn", title: "How Perfect Postcode works - Data sources, FAQ and support", description: "Learn how Perfect Postcode combines property prices, EPC records, travel times, crime, schools, broadband, noise, amenities and open data for postcode research.", indexable: true, }), "/pricing" => Some(SeoPage { canonical_path: "/pricing", title: "Perfect Postcode pricing - Lifetime property search map access", description: "Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.", indexable: true, }), "/property-price-map" => Some(SeoPage { canonical_path: "/property-price-map", title: "Property price map for England - Compare postcodes before viewing", description: "Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.", indexable: true, }), "/postcode-property-search" => Some(SeoPage { canonical_path: "/postcode-property-search", title: "Postcode property search - Find areas that match your criteria", description: "Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.", indexable: true, }), "/commute-property-search" => Some(SeoPage { canonical_path: "/commute-property-search", title: "Commute property search - Find places to live by travel time", description: "Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.", indexable: true, }), "/school-property-search" => Some(SeoPage { canonical_path: "/school-property-search", title: "School property search - Compare postcodes for family moves", description: "Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.", indexable: true, }), "/postcode-checker" => Some(SeoPage { canonical_path: "/postcode-checker", title: "Postcode checker - Property, crime, broadband, noise and schools", description: "Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.", indexable: true, }), "/property-search/birmingham" => Some(SeoPage { canonical_path: "/property-search/birmingham", title: "Birmingham property search - Compare postcodes by price and commute", description: "Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.", indexable: true, }), "/property-search/manchester" => Some(SeoPage { canonical_path: "/property-search/manchester", title: "Manchester property search - Compare postcodes before viewing", description: "Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.", indexable: true, }), "/property-search/bristol" => Some(SeoPage { canonical_path: "/property-search/bristol", title: "Bristol property search - Compare postcodes by commute and price", description: "Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.", indexable: true, }), "/data-sources" => Some(SeoPage { canonical_path: "/data-sources", title: "Perfect Postcode data sources - Property, schools, commute and local context", description: "Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.", indexable: true, }), "/methodology" => Some(SeoPage { canonical_path: "/methodology", title: "Perfect Postcode methodology - How to interpret postcode property data", description: "Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.", indexable: true, }), "/privacy-security" => Some(SeoPage { canonical_path: "/privacy-security", title: "Perfect Postcode privacy and security - Saved searches and account data", description: "Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.", indexable: true, }), "/terms" => Some(SeoPage { canonical_path: "/terms", title: "Terms of Service | Perfect Postcode", description: "The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.", indexable: true, }), "/privacy" => Some(SeoPage { canonical_path: "/privacy", title: "Privacy Policy | Perfect Postcode", description: "How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.", indexable: true, }), "/dashboard" => Some(SeoPage { canonical_path: "/dashboard", title: "Perfect Postcode dashboard", description: "Explore postcode property data, travel times, prices, schools, crime, noise, broadband and amenities on the interactive map.", indexable: false, }), "/saved" => Some(SeoPage { canonical_path: "/saved", title: "Perfect Postcode account", description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.", indexable: false, }), "/invites" => Some(SeoPage { canonical_path: "/invites", title: "Perfect Postcode account", description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.", indexable: false, }), "/account" => Some(SeoPage { canonical_path: "/account", title: "Perfect Postcode account", description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.", indexable: false, }), _ if path.starts_with("/invite/") => Some(SeoPage { canonical_path: "/invite", title: "You're invited to Perfect Postcode", description: "Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.", indexable: false, }), // Generated growth pages (cheaper-twin / value-index landing pages). Without this they // would 404 (should_return_404 keys off seo_page_for_path); see analysis/build_pages.py. _ => crate::generated_data_pages::data_page(path).map(|d| SeoPage { canonical_path: d.path, title: d.title, description: d.description, indexable: true, }), } } fn is_passthrough_path(path: &str) -> bool { path.starts_with("/api/") || path.starts_with("/pb/") || path.starts_with("/s/") || path.starts_with("/assets/") || matches!( path, "/health" | "/metrics" | "/robots.txt" | "/sitemap.xml" | "/favicon.svg" | "/bundle.js" | "/main.css" | "/house.png" ) || path .rsplit('/') .next() .is_some_and(|segment| segment.contains('.')) } fn should_return_404(path: &str) -> bool { !is_passthrough_path(path) && seo_page_for_path(path).is_none() } fn not_found_response(public_url: &str, path: &str) -> Response { let public_url_e = escape_attr(public_url); let path_e = escape_attr(path); let html = format!( r#" Page not found - Perfect Postcode
Perfect Postcode

Page not found

We couldn't find {path_e}.

It may have moved, or the link might be incomplete.

Back to Perfect Postcode
"# ); let mut response = Response::new(Body::from(html)); *response.status_mut() = StatusCode::NOT_FOUND; response.headers_mut().insert( header::CONTENT_TYPE, header::HeaderValue::from_static("text/html; charset=utf-8"), ); response } fn route_seo_tags( page: &SeoPage, path: &str, query_string: &str, public_url: &str, language: &str, ) -> String { let path_e = escape_attr(path); let query_e = escape_attr(query_string); // Generated data pages have a clean URL with no query, but their OG card must frame the finding's // map view rather than the default whole-England map. Use the registry's screenshot query. let screenshot_query_base = match crate::generated_data_pages::data_page(trim_trailing_slash(path)) { Some(dp) if !dp.screenshot_query.is_empty() => dp.screenshot_query, _ => query_string, }; let screenshot_query_string = query_string_with_language(screenshot_query_base, language); let screenshot_query_e = escape_attr(&screenshot_query_string); let public_url_e = escape_attr(public_url.trim_end_matches('/')); let canonical_path_e = escape_attr(page.canonical_path); let title_e = escape_attr(page.title); let description_e = escape_attr(page.description); let is_invite = path.starts_with("/invite/"); let og_image_url = if is_invite { if screenshot_query_string.is_empty() { format!("{public_url_e}/api/screenshot?og=1&path={path_e}") } else { format!("{public_url_e}/api/screenshot?og=1&path={path_e}&{screenshot_query_e}") } } else if screenshot_query_string.is_empty() { format!("{public_url_e}/api/screenshot?og=1") } else { format!("{public_url_e}/api/screenshot?og=1&{screenshot_query_e}") }; let canonical_url = format!("{public_url_e}{canonical_path_e}"); let og_url = if query_string.is_empty() { format!("{public_url_e}{path_e}") } else { format!("{public_url_e}{path_e}?{query_e}") }; let robots = if page.indexable { "index,follow" } else { "noindex,follow" }; // Emit hreflang alternates for indexable pages so search engines can serve // the right localized variant (the SPA switches language via ?lang=). let hreflang = if page.indexable { let mut tags = String::new(); for lang in supported_languages() { tags.push_str(&format!( "\n " )); } tags.push_str(&format!( "\n " )); tags } else { String::new() }; format!( r#" {hreflang} "# ) } fn inject_tags(mut html: String, page: &SeoPage, tags: &str) -> String { if let Some(start) = html.find("") { if let Some(end_offset) = html[start..].find("") { let end = start + end_offset + "".len(); html.replace_range( start..end, &format!("{}", escape_attr(page.title)), ); } } if let Some(start) = html.find(r#"') { let end = start + end_offset + 1; html.replace_range( start..end, &format!( r#""#, escape_attr(page.description) ), ); } } if html.contains(OG_PLACEHOLDER) { return html.replace(OG_PLACEHOLDER, tags); } if let Some(index) = html.find("") { html.insert_str(index, tags); } html } fn escape_json_for_script_tag(json: &str) -> String { json.replace('&', "\\u0026") .replace('<', "\\u003c") .replace('>', "\\u003e") } fn inject_bugsink_config(html: String, config: Option<&crate::bugsink::FrontendConfig>) -> String { if !html.contains(BUGSINK_CONFIG_PLACEHOLDER) { return html; } let json = config .and_then(|config| serde_json::to_string(config).ok()) .unwrap_or_else(|| "{}".to_string()); html.replace( BUGSINK_CONFIG_PLACEHOLDER, &escape_json_for_script_tag(&json), ) } pub async fn og_middleware(request: Request, next: Next) -> Response { let path = request.uri().path().to_string(); // Capture the query string before passing the request through let query_string = request.uri().query().unwrap_or("").to_string(); let language = language_from_accept_language( request .headers() .get(header::ACCEPT_LANGUAGE) .and_then(|value| value.to_str().ok()), ); // Get state from extensions let state = request.extensions().get::>().cloned(); if let Some(st) = &state { if !st.is_dev && should_return_404(&path) { return not_found_response(&st.public_url, &path); } } let response = next.run(request).await; // Only inject OG tags into SPA HTML responses, not proxied PocketBase responses if path.starts_with("/pb/") || path.starts_with("/api/") || path.starts_with("/s/") { return response; } let content_type = response .headers() .get(header::CONTENT_TYPE) .and_then(|val| val.to_str().ok()) .unwrap_or(""); if !content_type.contains("text/html") { return response; } let state = match state { Some(st) => st, None => return response, }; let page = seo_page_for_path(&path); if page.is_none() && state.bugsink_frontend_config.is_none() { return response; } let (mut parts, body) = response.into_parts(); let bytes = match to_bytes(body, HTML_BODY_LIMIT).await { Ok(bytes) => bytes, Err(err) => { warn!("Failed to buffer HTML body for SEO tag injection: {err}"); let mut response = Response::from_parts(parts, Body::empty()); *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; return response; } }; let html = String::from_utf8_lossy(&bytes).into_owned(); let mut html = inject_bugsink_config(html, state.bugsink_frontend_config.as_ref()); if let Some(page) = page { let tags = route_seo_tags(&page, &path, &query_string, &state.public_url, language); html = inject_tags(html, &page, &tags); } parts.headers.remove(header::CONTENT_LENGTH); Response::from_parts(parts, Body::from(html)) } #[cfg(test)] mod tests { use super::*; #[test] fn legal_pages_resolve_and_are_not_404() { for path in ["/terms", "/privacy", "/terms/", "/privacy/"] { assert!( seo_page_for_path(path).is_some(), "{path} should resolve to an SEO page (legal pages must render, not 404)" ); assert!( !should_return_404(path), "{path} must not return a server 404" ); } } #[test] fn unknown_paths_still_404() { assert!(should_return_404("/definitely-not-a-real-page")); } #[test] fn indexable_pages_emit_hreflang() { let page = seo_page_for_path("/terms").expect("terms page"); let tags = route_seo_tags(&page, "/terms", "", "https://perfect-postcode.co.uk", "en"); assert!(tags.contains(r#"hreflang="x-default""#)); assert!(tags.contains(r#"hreflang="de""#)); } #[test] fn noindex_pages_omit_hreflang() { let page = seo_page_for_path("/dashboard").expect("dashboard page"); let tags = route_seo_tags( &page, "/dashboard", "", "https://perfect-postcode.co.uk", "en", ); assert!(!tags.contains("hreflang")); } }