seems fine

This commit is contained in:
Andras Schmelczer 2026-05-05 22:29:28 +01:00
parent 48983e3b4b
commit 7a1696541f
37 changed files with 4999 additions and 1242 deletions

View file

@ -1,16 +1,26 @@
use std::sync::Arc;
use axum::body::Body;
use axum::body::{to_bytes, Body};
use axum::extract::Request;
use axum::http::header;
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use tracing::warn;
use crate::state::AppState;
const OG_PLACEHOLDER: &str =
r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
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());
@ -26,6 +36,279 @@ fn escape_attr(s: &str) -> String {
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: "Perfect Postcode - Find where to buy before browsing listings",
description: "Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before 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,
}),
"/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, saved properties and invitations.",
indexable: false,
}),
"/invites" => Some(SeoPage {
canonical_path: "/invites",
title: "Perfect Postcode account",
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
indexable: false,
}),
"/account" => Some(SeoPage {
canonical_path: "/account",
title: "Perfect Postcode account",
description: "Manage your Perfect Postcode account, saved searches, saved properties 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,
}),
_ => None,
}
}
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}/" />
</head>
<body>
<main>
<h1>Page not found</h1>
<p>The requested path was not found: {path_e}</p>
<p><a href="{public_url_e}/">Go to Perfect Postcode</a></p>
</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) -> String {
let path_e = escape_attr(path);
let query_e = escape_attr(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 query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}&amp;{query_e}")
}
} else if query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;{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"
};
format!(
r#"<meta name="robots" content="{robots}" />
<link rel="canonical" href="{canonical_url}" />
<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
}
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
@ -34,6 +317,12 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
// 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
@ -56,68 +345,25 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
None => return response,
};
let index_html = match &state.index_html {
Some(html) => html,
let page = match seo_page_for_path(&path) {
Some(page) => page,
None => return response,
};
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot).
// All URL components are HTML-escaped before interpolation into attributes
// because path/query are attacker-controlled.
let is_invite = path.starts_with("/invite/");
let path_e = escape_attr(&path);
let query_e = escape_attr(&query_string);
let public_url_e = escape_attr(&state.public_url);
let og_image_url = if is_invite {
// Include path= so the screenshot service navigates to /invite/CODE
if query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}&amp;{query_e}")
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;
}
} else if query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;{query_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 og_logo = format!("{public_url_e}/favicon.svg");
let (og_title, og_description) = if is_invite {
(
"You\u{2019}re invited to Perfect Postcode",
"Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.",
)
} else {
(
"Perfect Postcode \u{2014} Every neighbourhood in England",
"Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.",
)
};
let og_tags = format!(
r#"<meta property="og:title" content="{og_title}" />
<meta property="og:description" content="{og_description}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{og_url}" />
<meta property="og:logo" content="{og_logo}" />
<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="{og_title}" />
<meta name="twitter:description" content="{og_description}" />"#
);
let html = index_html.replace(OG_PLACEHOLDER, &og_tags);
let (parts, _body) = response.into_parts();
let html = String::from_utf8_lossy(&bytes).into_owned();
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url);
let html = inject_tags(html, &page, &tags);
parts.headers.remove(header::CONTENT_LENGTH);
Response::from_parts(parts, Body::from(html))
}