This commit is contained in:
Andras Schmelczer 2026-05-14 22:07:14 +01:00
parent 084117cea8
commit a8de0a614d
36 changed files with 1329 additions and 522 deletions

View file

@ -30,7 +30,7 @@ pub enum CheckoutStart {
pub enum CheckoutCompletion {
Grant(VerifiedCheckout),
AlreadyHandled,
AlreadyHandled(VerifiedCheckout),
Rejected(String),
}
@ -48,7 +48,6 @@ struct PendingCheckout {
id: String,
user_id: String,
stripe_session_id: String,
stripe_payment_intent_id: String,
checkout_url: String,
amount_pence: u64,
expected_total_pence: u64,
@ -258,10 +257,8 @@ pub async fn verify_checkout_completion(
}
};
if checkout.status == "completed" {
return Ok(CheckoutCompletion::AlreadyHandled);
}
if checkout.status != "pending" && checkout.status != "expired" {
let already_completed = checkout.status == "completed";
if !already_completed && checkout.status != "pending" && checkout.status != "expired" {
return Ok(CheckoutCompletion::Rejected(format!(
"checkout reservation is {}",
checkout.status
@ -332,14 +329,20 @@ pub async fn verify_checkout_completion(
));
}
Ok(CheckoutCompletion::Grant(VerifiedCheckout {
let verified = VerifiedCheckout {
reservation_id: checkout.id,
user_id: checkout.user_id,
stripe_session_id: session_id.to_string(),
payment_intent_id: payment_intent_id.to_string(),
paid_amount_pence: amount_total,
referral_invite_id: checkout.referral_invite_id,
}))
};
if already_completed {
Ok(CheckoutCompletion::AlreadyHandled(verified))
} else {
Ok(CheckoutCompletion::Grant(verified))
}
}
pub async fn mark_checkout_completed(
@ -421,14 +424,6 @@ async fn complete_verified_checkout_locked(
return Err(anyhow!("checkout reservation is {}", live_checkout.status));
}
grant_license(state, &checkout.user_id).await?;
mark_checkout_completed(
state,
&checkout.reservation_id,
checkout.paid_amount_pence,
&checkout.payment_intent_id,
)
.await?;
if !checkout.referral_invite_id.is_empty() {
mark_referral_invite_used(
state,
@ -438,6 +433,14 @@ async fn complete_verified_checkout_locked(
)
.await?;
}
grant_license(state, &checkout.user_id).await?;
mark_checkout_completed(
state,
&checkout.reservation_id,
checkout.paid_amount_pence,
&checkout.payment_intent_id,
)
.await?;
Ok(())
}
@ -459,65 +462,10 @@ pub async fn grant_license_with_pricing_lock(
result
}
pub async fn reverse_license_for_payment_intent(
state: &AppState,
payment_intent_id: &str,
reason: &str,
) -> anyhow::Result<Option<String>> {
if !is_safe_stripe_session_id(payment_intent_id) {
return Err(anyhow!("invalid Stripe payment intent id"));
}
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
let pricing_lock = acquire_pocketbase_lock(
state,
CHECKOUT_PRICING_LOCK_NAME,
CHECKOUT_PRICING_LOCK_TTL_SECS,
)
.await?;
let result = reverse_license_for_payment_intent_locked(state, payment_intent_id, reason).await;
if let Err(err) = pricing_lock.release().await {
warn!("Failed to release checkout pricing lock: {err}");
}
result
}
async fn reverse_license_for_payment_intent_locked(
state: &AppState,
payment_intent_id: &str,
reason: &str,
) -> anyhow::Result<Option<String>> {
let Some(checkout) = find_checkout_by_payment_intent(state, payment_intent_id).await? else {
return Ok(None);
};
if checkout.stripe_payment_intent_id != payment_intent_id {
return Err(anyhow!("checkout payment intent mismatch"));
}
if checkout.status == "refunded" || checkout.status == "disputed" {
return Ok(Some(checkout.user_id));
}
if checkout.status != "completed" {
return Ok(Some(checkout.user_id));
}
let reversed_status = if reason.contains("dispute") {
"disputed"
} else {
"refunded"
};
revoke_license(state, &checkout.user_id).await?;
mark_checkout_reversed(state, &checkout.id, reversed_status, reason).await?;
Ok(Some(checkout.user_id))
}
pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
set_user_subscription(state, user_id, "licensed").await
}
async fn revoke_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
set_user_subscription(state, user_id, "free").await
}
async fn set_user_subscription(
state: &AppState,
user_id: &str,
@ -768,20 +716,13 @@ async fn count_active_pending_checkouts(state: &AppState, now: u64) -> anyhow::R
async fn find_active_checkout_for_user(
state: &AppState,
user_id: &str,
_discount_coupon_id: &str,
_referral_invite_id: &str,
discount_coupon_id: &str,
referral_invite_id: &str,
now: u64,
) -> anyhow::Result<Option<PendingCheckout>> {
if !is_safe_pocketbase_id(user_id) {
return Err(anyhow!("invalid PocketBase user id"));
}
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!(
"status=\"pending\" && expires_at_unix>={now} && user=\"{}\"",
user_id
);
let filter = active_checkout_filter(user_id, discount_coupon_id, referral_invite_id, now)?;
let url = format!(
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
urlencoding::encode(&filter)
@ -804,6 +745,27 @@ async fn find_active_checkout_for_user(
item.map(parse_pending_checkout).transpose()
}
fn active_checkout_filter(
user_id: &str,
discount_coupon_id: &str,
referral_invite_id: &str,
now: u64,
) -> anyhow::Result<String> {
if !is_safe_pocketbase_id(user_id) {
return Err(anyhow!("invalid PocketBase user id"));
}
if !discount_coupon_id.is_empty() && !is_safe_stripe_session_id(discount_coupon_id) {
return Err(anyhow!("invalid Stripe coupon id"));
}
if !referral_invite_id.is_empty() && !is_safe_pocketbase_id(referral_invite_id) {
return Err(anyhow!("invalid PocketBase referral invite id"));
}
Ok(format!(
"status=\"pending\" && expires_at_unix>={now} && user=\"{user_id}\" && discount_coupon_id=\"{discount_coupon_id}\" && referral_invite_id=\"{referral_invite_id}\""
))
}
async fn expire_stale_pending_checkouts(state: &AppState, now: u64) -> anyhow::Result<()> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
@ -1076,31 +1038,6 @@ async fn mark_checkout_status(
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
}
async fn mark_checkout_reversed(
state: &AppState,
reservation_id: &str,
status: &str,
reason: &str,
) -> anyhow::Result<()> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
let resp = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"status": status,
"reversal_reason": reason,
}))
.send()
.await?;
ensure_success(resp)
.await
.with_context(|| format!("PocketBase checkout reversal update failed for {reservation_id}"))
}
async fn find_checkout_by_stripe_session(
state: &AppState,
stripe_session_id: &str,
@ -1130,35 +1067,6 @@ async fn find_checkout_by_stripe_session(
item.map(parse_pending_checkout).transpose()
}
async fn find_checkout_by_payment_intent(
state: &AppState,
payment_intent_id: &str,
) -> anyhow::Result<Option<PendingCheckout>> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("stripe_payment_intent_id=\"{}\"", payment_intent_id);
let url = format!(
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
ensure_success_ref(&resp).await?;
let body: Value = resp.json().await?;
let item = body["items"]
.as_array()
.and_then(|items| items.first())
.cloned();
item.map(parse_pending_checkout).transpose()
}
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
Ok(PendingCheckout {
id: item["id"]
@ -1173,10 +1081,6 @@ fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
.as_str()
.unwrap_or_default()
.to_string(),
stripe_payment_intent_id: item["stripe_payment_intent_id"]
.as_str()
.unwrap_or_default()
.to_string(),
checkout_url: item["checkout_url"]
.as_str()
.unwrap_or_default()
@ -1242,3 +1146,40 @@ async fn ensure_success_ref(resp: &reqwest::Response) -> anyhow::Result<()> {
Err(anyhow!("upstream returned {}", resp.status()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn active_checkout_filter_includes_empty_context_for_standard_checkout() {
let filter = active_checkout_filter("abc123", "", "", 42).unwrap();
assert_eq!(
filter,
"status=\"pending\" && expires_at_unix>=42 && user=\"abc123\" && discount_coupon_id=\"\" && referral_invite_id=\"\""
);
}
#[test]
fn active_checkout_filter_includes_referral_context() {
let filter = active_checkout_filter("user123", "coupon_30", "invite123", 99).unwrap();
assert_eq!(
filter,
"status=\"pending\" && expires_at_unix>=99 && user=\"user123\" && discount_coupon_id=\"coupon_30\" && referral_invite_id=\"invite123\""
);
}
#[test]
fn active_checkout_filter_rejects_unsafe_context_values() {
assert!(active_checkout_filter("user123", "bad\"coupon", "", 1).is_err());
assert!(active_checkout_filter("user123", "", "bad-invite", 1).is_err());
assert!(active_checkout_filter("bad-user", "", "", 1).is_err());
}
#[test]
fn expected_total_for_referral_discount_rounds_down_like_stripe_amount_math() {
assert_eq!(expected_total_for_checkout(999, Some("coupon_30")), 699);
assert_eq!(expected_total_for_checkout(1, Some("coupon_30")), 1);
assert_eq!(expected_total_for_checkout(999, None), 999);
}
}

101
server-rs/src/language.rs Normal file
View file

@ -0,0 +1,101 @@
pub const DEFAULT_LANGUAGE: &str = "en";
const SUPPORTED_LANGUAGES: &[&str] = &["en", "fr", "de", "zh", "hi", "hu"];
pub fn supported_language(value: &str) -> Option<&'static str> {
let value = value.trim().to_ascii_lowercase();
if value.is_empty() || value == "*" {
return None;
}
let primary = value.split('-').next().unwrap_or("");
SUPPORTED_LANGUAGES
.iter()
.copied()
.find(|language| value == *language || primary == *language)
}
pub fn language_from_accept_language(header: Option<&str>) -> &'static str {
let Some(header) = header else {
return DEFAULT_LANGUAGE;
};
let mut best_language = None;
let mut best_quality = 0.0f32;
for item in header.split(',') {
let mut parts = item.split(';');
let tag = parts.next().unwrap_or("").trim();
let mut quality = 1.0f32;
for param in parts {
let param = param.trim();
if let Some(value) = param.strip_prefix("q=") {
quality = value.parse::<f32>().unwrap_or(0.0);
}
}
if quality <= 0.0 {
continue;
}
if let Some(language) = supported_language(tag) {
if quality > best_quality {
best_language = Some(language);
best_quality = quality;
}
}
}
best_language.unwrap_or(DEFAULT_LANGUAGE)
}
pub fn query_string_with_language(query_string: &str, language: &str) -> String {
if url::form_urlencoded::parse(query_string.as_bytes()).any(|(key, _)| key == "lang") {
return query_string.to_string();
}
if query_string.is_empty() {
format!("lang={language}")
} else {
format!("{query_string}&lang={language}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_browser_language_tags_to_supported_languages() {
assert_eq!(supported_language("fr-CA"), Some("fr"));
assert_eq!(supported_language("zh-Hans-CN"), Some("zh"));
assert_eq!(supported_language("es"), None);
}
#[test]
fn chooses_highest_quality_supported_language() {
assert_eq!(
language_from_accept_language(Some("en-US;q=0.6, fr-FR;q=0.9, de;q=0.7")),
"fr"
);
assert_eq!(
language_from_accept_language(Some("es-ES, hi-IN;q=0.8")),
"hi"
);
assert_eq!(language_from_accept_language(Some("es-ES")), "en");
}
#[test]
fn appends_language_without_overriding_explicit_query_language() {
assert_eq!(query_string_with_language("", "de"), "lang=de");
assert_eq!(
query_string_with_language("lat=51.5&lon=-0.1", "de"),
"lat=51.5&lon=-0.1&lang=de"
);
assert_eq!(
query_string_with_language("lang=fr&lat=1", "de"),
"lang=fr&lat=1"
);
}
}

View file

@ -6,15 +6,16 @@ use parking_lot::RwLock;
use rustc_hash::FxHashMap;
use serde_json::{json, Value};
use tracing::warn;
use url::form_urlencoded;
use crate::auth::PocketBaseUser;
use crate::consts::FREE_ZONE_BOUNDS;
use crate::consts::{
FREE_ZONE_BOUNDS, MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM,
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS,
};
use crate::pocketbase::get_superuser_token;
use crate::state::AppState;
const SHARE_CACHE_TTL_SECS: u64 = 300;
const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
#[derive(Clone, Copy, Debug)]
pub struct ShareBounds {
pub south: f64,
@ -72,9 +73,8 @@ impl Default for ShareBoundsCache {
}
/// Resolve a share code to the bbox the share grants access to.
/// Looks up the stored params for the code in PocketBase, parses lat/lon/zoom,
/// and derives a generous bbox sized to roughly 4× the viewport at that zoom.
/// Returns `None` if the code is invalid or unknown.
/// Looks up an explicit server-created grant on the short URL record. Legacy
/// records that only stored raw params intentionally grant no access.
pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
if !is_valid_share_code(code) {
return None;
@ -127,29 +127,68 @@ async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds>
return None;
}
let json: Value = resp.json().await.ok()?;
let params = json["items"].as_array()?.first()?.get("params")?.as_str()?;
parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom))
let item = json["items"].as_array()?.first()?;
let bounds = ShareBounds {
south: number_field(item, "share_south")?,
west: number_field(item, "share_west")?,
north: number_field(item, "share_north")?,
east: number_field(item, "share_east")?,
};
is_valid_share_bounds(bounds).then_some(bounds)
}
/// Pull `lat`, `lon`, `zoom` out of an already-encoded query string like
/// `lat=51.5&lon=-0.1&zoom=12&filter=...`. Returns `None` if any of the three
/// is missing or unparseable — those are the only fields we need for sizing.
fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> {
fn number_field(item: &Value, field: &str) -> Option<f64> {
item.get(field)?.as_f64().filter(|value| value.is_finite())
}
/// Build share params and bounds for a new share code. If the source view is
/// broader than a share grant may cover, clamp the stored zoom around the same
/// center so recipients open inside the created grant instead of being blocked
/// on first load.
pub fn share_params_and_bounds_from_params(params: &str) -> Option<(String, ShareBounds)> {
let mut lat: Option<f64> = None;
let mut lon: Option<f64> = None;
let mut zoom: Option<f64> = None;
for pair in params.split('&') {
let mut it = pair.splitn(2, '=');
let key = it.next()?;
let val = it.next().unwrap_or("");
match key {
"lat" => lat = val.parse().ok(),
"lon" => lon = val.parse().ok(),
"zoom" => zoom = val.parse().ok(),
let mut pairs = Vec::new();
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
match key.as_ref() {
"lat" => lat = value.parse().ok(),
"lon" => lon = value.parse().ok(),
"zoom" => zoom = value.parse().ok(),
_ => {}
}
pairs.push((key.into_owned(), value.into_owned()));
}
Some((lat?, lon?, zoom?))
let lat = lat?;
let lon = lon?;
let zoom = zoom?;
if !lat.is_finite()
|| !lon.is_finite()
|| !zoom.is_finite()
|| !(-90.0..=90.0).contains(&lat)
|| !(-180.0..=180.0).contains(&lon)
{
return None;
}
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
let bounds = bounds_from_view(lat, lon, zoom);
if !is_valid_share_bounds(bounds) {
return None;
}
let mut out = form_urlencoded::Serializer::new(String::new());
for (key, value) in pairs {
if key == "zoom" {
out.append_pair(&key, &format!("{zoom:.1}"));
} else {
out.append_pair(&key, &value);
}
}
Some((out.finish(), bounds))
}
/// Derive the share bbox from the share's center lat/lon and zoom.
@ -160,17 +199,30 @@ fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> {
/// ~2 viewports per side (~4 viewports total area). Lat is scaled by 0.6
/// to roughly match the latitude compression at UK latitudes.
fn bounds_from_view(lat: f64, lon: f64, zoom: f64) -> ShareBounds {
let zoom = zoom.clamp(0.0, 20.0);
let half_lon = (1800.0 / 2.0_f64.powf(zoom)).min(180.0);
let half_lat = (half_lon * 0.6).min(85.0);
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
let half_lon = (1800.0 / 2.0_f64.powf(zoom))
.min(MAX_SHARE_LON_SPAN / 2.0)
.min(180.0);
let half_lat = (half_lon * 0.6).min(MAX_SHARE_LAT_SPAN / 2.0).min(85.0);
ShareBounds {
south: lat - half_lat,
north: lat + half_lat,
west: lon - half_lon,
east: lon + half_lon,
south: (lat - half_lat).max(-90.0),
north: (lat + half_lat).min(90.0),
west: (lon - half_lon).max(-180.0),
east: (lon + half_lon).min(180.0),
}
}
pub fn is_valid_share_bounds(bounds: ShareBounds) -> bool {
let values = [bounds.south, bounds.west, bounds.north, bounds.east];
values.iter().all(|value| value.is_finite())
&& bounds.south >= -90.0
&& bounds.north <= 90.0
&& bounds.west >= -180.0
&& bounds.east <= 180.0
&& bounds.south <= bounds.north
&& bounds.west <= bounds.east
}
/// Check whether the user is allowed to query data at the given bounds.
/// Licensed users and admins bypass the check entirely.
/// Free/anonymous users get 403 unless the bounds fall inside the free zone
@ -224,3 +276,73 @@ pub fn check_license_point(
) -> Result<(), axum::response::Response> {
check_license_bounds(user, (lat, lon, lat, lon), share_bounds)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_close(actual: f64, expected: f64) {
assert!(
(actual - expected).abs() < 1e-9,
"expected {actual} to be close to {expected}"
);
}
#[test]
fn share_creation_clamps_over_broad_view_to_center() {
let (params, bounds) =
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=4.2&filter=price%3A1%3A2")
.unwrap();
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
assert!(parsed.contains(&("zoom".to_string(), "11.0".to_string())));
assert!(parsed.contains(&("filter".to_string(), "price:1:2".to_string())));
assert_close((bounds.south + bounds.north) / 2.0, 51.5);
assert_close((bounds.west + bounds.east) / 2.0, -0.1);
assert!(bounds.north - bounds.south <= MAX_SHARE_LAT_SPAN);
assert!(bounds.east - bounds.west <= MAX_SHARE_LON_SPAN);
}
#[test]
fn share_creation_keeps_specific_zoom_inside_limit() {
let (params, bounds) =
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=13.3").unwrap();
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
assert!(parsed.contains(&("zoom".to_string(), "13.3".to_string())));
assert!(bounds.north - bounds.south < MAX_SHARE_LAT_SPAN);
assert!(bounds.east - bounds.west < MAX_SHARE_LON_SPAN);
}
#[test]
fn share_consumption_accepts_bounds_larger_than_creation_limit() {
assert!(is_valid_share_bounds(ShareBounds {
south: 40.0,
west: -10.0,
north: 60.0,
east: 10.0,
}));
}
#[test]
fn share_consumption_still_rejects_malformed_bounds() {
assert!(!is_valid_share_bounds(ShareBounds {
south: 60.0,
west: -10.0,
north: 40.0,
east: 10.0,
}));
assert!(!is_valid_share_bounds(ShareBounds {
south: 40.0,
west: -181.0,
north: 60.0,
east: 10.0,
}));
}
}

View file

@ -7,6 +7,7 @@ use axum::middleware::Next;
use axum::response::Response;
use tracing::warn;
use crate::language::{language_from_accept_language, query_string_with_language};
use crate::state::AppState;
const OG_PLACEHOLDER: &str =
@ -140,19 +141,19 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
"/saved" => Some(SeoPage {
canonical_path: "/saved",
title: "Perfect Postcode account",
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
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, saved properties and invitations.",
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, saved properties and invitations.",
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
indexable: false,
}),
_ if path.starts_with("/invite/") => Some(SeoPage {
@ -223,9 +224,17 @@ fn not_found_response(public_url: &str, path: &str) -> Response {
response
}
fn route_seo_tags(page: &SeoPage, path: &str, query_string: &str, public_url: &str) -> String {
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);
let screenshot_query_string = query_string_with_language(query_string, 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);
@ -233,15 +242,15 @@ fn route_seo_tags(page: &SeoPage, path: &str, query_string: &str, public_url: &s
let is_invite = path.starts_with("/invite/");
let og_image_url = if is_invite {
if query_string.is_empty() {
if screenshot_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}")
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}&amp;{screenshot_query_e}")
}
} else if query_string.is_empty() {
} else if screenshot_query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;{query_e}")
format!("{public_url_e}/api/screenshot?og=1&amp;{screenshot_query_e}")
};
let canonical_url = format!("{public_url_e}{canonical_path_e}");
@ -313,6 +322,12 @@ 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();
@ -362,7 +377,7 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
};
let html = String::from_utf8_lossy(&bytes).into_owned();
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url);
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url, language);
let html = inject_tags(html, &page, &tags);
parts.headers.remove(header::CONTENT_LENGTH);
Response::from_parts(parts, Body::from(html))

View file

@ -80,6 +80,28 @@ pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCod
let (south, west, north, east) = (parts[0], parts[1], parts[2], parts[3]);
if ![south, west, north, east]
.iter()
.all(|value| value.is_finite())
{
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds: values must be finite numbers".into(),
));
}
if !(-90.0..=90.0).contains(&south) || !(-90.0..=90.0).contains(&north) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds: latitude must be between -90 and 90".into(),
));
}
if !(-180.0..=180.0).contains(&west) || !(-180.0..=180.0).contains(&east) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds: longitude must be between -180 and 180".into(),
));
}
// Validate that bounds are not inverted
if south > north {
return Err((
@ -112,8 +134,8 @@ mod tests {
(1.0, 2.0, 3.0, 4.0)
);
assert_eq!(
parse_bounds("-51.5, -0.1, 51.6, 0.2").unwrap(),
(-51.5, -0.1, 51.6, 0.2)
parse_bounds("51.5, -0.1, 51.6, 0.2").unwrap(),
(51.5, -0.1, 51.6, 0.2)
);
}
@ -133,6 +155,18 @@ mod tests {
assert!(parse_bounds("51.0,0.5,52.0,-0.5").is_err());
}
#[test]
fn parse_bounds_rejects_non_finite_values() {
assert!(parse_bounds("NaN,0,1,1").is_err());
assert!(parse_bounds("0,0,inf,1").is_err());
}
#[test]
fn parse_bounds_accepts_world_sized_bounds() {
assert!(parse_bounds("-90,-180,90,180").is_ok());
assert!(parse_bounds("35.8,-45.0,67.2,45.0").is_ok());
}
#[test]
fn h3_cell_bounds_applies_buffer() {
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();

View file

@ -12,6 +12,8 @@ use crate::state::SharedState;
const FILTER_GROUP_ORDER: &[&str] = &["Transport", "Property prices", "Properties", "Amenities"];
const LAST_FILTER_GROUPS: &[&str] = &["Area development"];
const POI_DISTANCE_SLIDER_MIN_KM: f32 = 0.0;
const POI_DISTANCE_SLIDER_MAX_KM: f32 = 5.0;
fn is_empty(val: &str) -> bool {
val.is_empty()
@ -163,8 +165,8 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
let is_park = category.eq_ignore_ascii_case("park");
dynamic_poi_features.push(FeatureInfo::Numeric {
name: name.clone(),
min: stats.slider_min,
max: stats.slider_max,
min: POI_DISTANCE_SLIDER_MIN_KM,
max: POI_DISTANCE_SLIDER_MAX_KM,
step: 0.1,
histogram: stats.histogram.clone(),
description: if is_park {
@ -187,7 +189,7 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
prefix: "",
suffix: " km",
raw: false,
absolute: false,
absolute: true,
});
} else if let Some(category) = features::dynamic_poi_count_category(name) {
let stats = &data.poi_metrics.feature_stats[feat_idx];

View file

@ -193,13 +193,31 @@ async fn verify_is_admin(
Ok(body["is_admin"].as_bool().unwrap_or(false))
}
async fn lookup_unused_invite(
fn redeemable_invite_filter(code: &str, user_id: &str) -> Result<String, &'static str> {
validate_invite_code(code)?;
if user_id.is_empty()
|| user_id.len() > 32
|| !user_id.bytes().all(|b| b.is_ascii_alphanumeric())
{
return Err("Invalid user id");
}
Ok(format!(
"code=\"{}\" && (used_by_id=\"\" || used_by_id=\"{}\")",
code, user_id
))
}
async fn lookup_redeemable_invite(
state: &AppState,
pb_url: &str,
token: &str,
code: &str,
user_id: &str,
) -> Result<Option<serde_json::Value>, Response> {
let filter = format!("code=\"{}\" && used_by_id=\"\"", code);
let filter = match redeemable_invite_filter(code, user_id) {
Ok(filter) => filter,
Err(msg) => return Err((StatusCode::BAD_REQUEST, msg).into_response()),
};
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
@ -590,7 +608,7 @@ pub async fn post_redeem_invite(
}
};
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
let invite = match lookup_redeemable_invite(&state, pb_url, &token, &req.code, &user.id).await {
Ok(Some(invite)) => invite,
Ok(None) => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
@ -617,13 +635,17 @@ pub async fn post_redeem_invite(
return StatusCode::BAD_GATEWAY.into_response();
}
};
let used_by_id = invite["used_by_id"].as_str().unwrap_or_default();
if !used_by_id.is_empty() && used_by_id != user.id {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response();
}
if invite_type == "admin" {
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
return response;
}
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
return response;
}
@ -635,6 +657,10 @@ pub async fn post_redeem_invite(
.into_response();
}
if !used_by_id.is_empty() {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response();
}
match active_referral_checkout_user(&state, invite_id).await {
Ok(Some(active_user_id)) if active_user_id != user.id => {
return (
@ -663,6 +689,26 @@ pub async fn post_redeem_invite(
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State<Arc<SharedState>>,

View file

@ -2,16 +2,22 @@ use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Json;
use axum::Extension;
use serde::{Deserialize, Serialize};
use crate::auth::OptionalUser;
use crate::licensing::{check_license_point, resolve_share_code};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
#[derive(Deserialize)]
pub struct JourneyQuery {
postcode: String,
mode: String,
slug: String,
share: Option<String>,
}
#[derive(Serialize)]
@ -26,16 +32,30 @@ pub struct JourneyResponse {
pub async fn get_journey(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
query: axum::extract::Query<JourneyQuery>,
) -> Result<Json<JourneyResponse>, (StatusCode, String)> {
) -> Result<Json<JourneyResponse>, axum::response::Response> {
let state = shared.load_state();
let store = &state.travel_time_store;
let postcode = normalize_postcode(&query.postcode);
let pc_idx = state
.postcode_data
.postcode_to_idx
.get(&postcode)
.copied()
.ok_or_else(|| (StatusCode::NOT_FOUND, "Postcode not found").into_response())?;
let (lat, lon) = state.postcode_data.centroids[pc_idx];
let share_bounds = resolve_share_code(&state, query.share.as_deref()).await;
check_license_point(&user.0, lat as f64, lon as f64, share_bounds)?;
if !store.has_destination(&query.mode, &query.slug) {
return Err((
StatusCode::NOT_FOUND,
format!("No travel data for mode={} slug={}", query.mode, query.slug),
));
)
.into_response());
}
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
@ -43,9 +63,10 @@ pub async fn get_journey(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load travel data: {e}"),
)
.into_response()
})?;
let row = travel_data.get(&query.postcode);
let row = travel_data.get(&postcode);
let journey = row
.and_then(|r| r.journey.as_ref())
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());

View file

@ -9,6 +9,31 @@ use tracing::warn;
use crate::state::SharedState;
/// PocketBase API paths the frontend is allowed to reach via /pb/*.
/// Everything else (admins API, settings, logs, backups, collection schema,
/// arbitrary collection records like checkout_sessions/invites/short_urls)
/// is rejected at the proxy layer as defense-in-depth on top of PB's own
/// collection rules.
fn is_allowed_pb_path(path: &str) -> bool {
// Exact paths
if matches!(
path,
"/api/health" | "/api/oauth2-redirect" | "/api/realtime"
) {
return true;
}
// Prefix-allowed paths. The trailing slash is intentional — without it,
// `/api/collections/users` (the schema endpoint) would match.
const ALLOWED_PREFIXES: &[&str] = &[
"/api/collections/users/",
"/api/collections/saved_searches/",
"/api/files/",
];
ALLOWED_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix))
}
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
/// responses are passed through to the browser (needed for OAuth flows).
/// No overall timeout because SSE (Server-Sent Events) connections used by
@ -31,6 +56,13 @@ pub async fn proxy_to_pocketbase(
let path = req.uri().path();
let target_path = path.strip_prefix("/pb").unwrap_or(path);
if !is_allowed_pb_path(target_path) {
warn!(path = %target_path, "Rejected PocketBase proxy request to disallowed path");
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap();
}
let query = req
.uri()
.query()
@ -55,20 +87,9 @@ pub async fn proxy_to_pocketbase(
}
}
// Forward client IP so PocketBase rate-limits per-user, not per-server.
// Prefer existing X-Forwarded-For (from reverse proxy), fall back to X-Real-IP.
if let Some(xff) = req.headers().get("x-forwarded-for") {
builder = builder.header("X-Forwarded-For", xff.clone());
// First IP in the chain is the original client
if let Ok(s) = xff.to_str() {
if let Some(client_ip) = s.split(',').next().map(str::trim) {
builder = builder.header("X-Real-IP", client_ip);
}
}
} else if let Some(real_ip) = req.headers().get("x-real-ip") {
builder = builder.header("X-Forwarded-For", real_ip.clone());
builder = builder.header("X-Real-IP", real_ip.clone());
}
// Do not forward client-supplied X-Forwarded-For/X-Real-IP. PocketBase
// may use trusted proxy headers for rate limits, so accepting public
// values here lets callers choose their own source IP.
// Forward body
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {

View file

@ -7,6 +7,7 @@ use axum::response::IntoResponse;
use metrics::histogram;
use tracing::{info, warn};
use crate::language::{language_from_accept_language, query_string_with_language};
use crate::state::{AppState, SharedState};
/// Fetch a JPEG screenshot from the screenshot service.
@ -48,9 +49,19 @@ pub async fn get_screenshot(
let qs = uri.query().unwrap_or_default();
let auth = headers.get(header::AUTHORIZATION);
let is_og = qs.contains("og=1");
let query_string = if is_og {
let language = language_from_accept_language(
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok()),
);
query_string_with_language(qs, language)
} else {
qs.to_string()
};
let t0 = std::time::Instant::now();
let result = fetch_screenshot_bytes(&state, qs, auth).await;
let result = fetch_screenshot_bytes(&state, &query_string, auth).await;
let kind = if is_og { "og" } else { "export" };
histogram!("screenshot_duration_seconds", "kind" => kind).record(t0.elapsed().as_secs_f64());