531 lines
21 KiB
Rust
531 lines
21 KiB
Rust
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#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
|
|
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<SeoPage> {
|
|
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#"<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="robots" content="noindex,follow" />
|
|
<title>Page not found - Perfect Postcode</title>
|
|
<meta name="description" content="This Perfect Postcode page could not be found." />
|
|
<link rel="canonical" href="{public_url_e}/" />
|
|
<style>
|
|
:root {{ color-scheme: dark; }}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #0b1220;
|
|
color: #e7ecf3;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
text-align: center;
|
|
padding: 24px;
|
|
}}
|
|
main {{ max-width: 32rem; }}
|
|
.brand {{ font-weight: 700; letter-spacing: .01em; color: #2dd4bf; font-size: 1.1rem; margin-bottom: 1.5rem; }}
|
|
h1 {{ font-size: 2rem; margin: 0 0 .5rem; }}
|
|
p {{ color: #9fb0c3; line-height: 1.6; margin: .5rem 0; }}
|
|
code {{ color: #cbd5e1; background: rgba(148,163,184,.15); padding: .1rem .35rem; border-radius: .25rem; word-break: break-all; }}
|
|
a.cta {{
|
|
display: inline-block;
|
|
margin-top: 1.5rem;
|
|
padding: .65rem 1.25rem;
|
|
border-radius: .5rem;
|
|
background: #2dd4bf;
|
|
color: #0b1220;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}}
|
|
a.cta:hover {{ background: #5eead4; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="brand">Perfect Postcode</div>
|
|
<h1>Page not found</h1>
|
|
<p>We couldn't find <code>{path_e}</code>.</p>
|
|
<p>It may have moved, or the link might be incomplete.</p>
|
|
<a class="cta" href="{public_url_e}/">Back to Perfect Postcode</a>
|
|
</main>
|
|
</body>
|
|
</html>"#
|
|
);
|
|
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 <link rel=\"alternate\" hreflang=\"{lang}\" href=\"{public_url_e}{canonical_path_e}?lang={lang}\" />"
|
|
));
|
|
}
|
|
tags.push_str(&format!(
|
|
"\n <link rel=\"alternate\" hreflang=\"x-default\" href=\"{public_url_e}{canonical_path_e}\" />"
|
|
));
|
|
tags
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
format!(
|
|
r#"<meta name="robots" content="{robots}" />
|
|
<link rel="canonical" href="{canonical_url}" />{hreflang}
|
|
<meta property="og:title" content="{title_e}" />
|
|
<meta property="og:description" content="{description_e}" />
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:url" content="{og_url}" />
|
|
<meta property="og:site_name" content="Perfect Postcode" />
|
|
<meta property="og:logo" content="{public_url_e}/favicon.svg" />
|
|
<meta property="og:image" content="{og_image_url}" />
|
|
<meta property="og:image:width" content="1200" />
|
|
<meta property="og:image:height" content="630" />
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content="{title_e}" />
|
|
<meta name="twitter:description" content="{description_e}" />
|
|
<meta name="twitter:image" content="{og_image_url}" />"#
|
|
)
|
|
}
|
|
|
|
fn inject_tags(mut html: String, page: &SeoPage, tags: &str) -> String {
|
|
if let Some(start) = html.find("<title>") {
|
|
if let Some(end_offset) = html[start..].find("</title>") {
|
|
let end = start + end_offset + "</title>".len();
|
|
html.replace_range(
|
|
start..end,
|
|
&format!("<title>{}</title>", escape_attr(page.title)),
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(start) = html.find(r#"<meta name="description""#) {
|
|
if let Some(end_offset) = html[start..].find('>') {
|
|
let end = start + end_offset + 1;
|
|
html.replace_range(
|
|
start..end,
|
|
&format!(
|
|
r#"<meta name="description" content="{}" />"#,
|
|
escape_attr(page.description)
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if html.contains(OG_PLACEHOLDER) {
|
|
return html.replace(OG_PLACEHOLDER, tags);
|
|
}
|
|
|
|
if let Some(index) = html.find("</head>") {
|
|
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::<Arc<AppState>>().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"));
|
|
}
|
|
}
|