Improve FAQ & video rendering, tighten homepage and CSS

This commit is contained in:
Andras Schmelczer 2026-05-04 22:07:30 +01:00
parent 05a1f316e1
commit c69bb0d614
48 changed files with 4689 additions and 1077 deletions

File diff suppressed because it is too large Load diff

View file

@ -1227,7 +1227,7 @@ mod tests {
let mid_value = 50.0;
let bin = hist.bin_for_value(mid_value);
assert!(bin >= 1 && bin <= 8);
assert!((1..=8).contains(&bin));
}
#[test]
@ -1276,7 +1276,7 @@ mod tests {
#[test]
fn count_skips_nan() {
let values = vec![1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0];
let values = [1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0];
let count = values.iter().filter(|v| v.is_finite()).count();
assert_eq!(count, 3);
}

View file

@ -1,17 +1,189 @@
use std::time::Instant;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
use parking_lot::RwLock;
use rustc_hash::FxHashMap;
use serde_json::{json, Value};
use tracing::warn;
use crate::auth::PocketBaseUser;
use crate::consts::FREE_ZONE_BOUNDS;
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,
pub west: f64,
pub north: f64,
pub east: f64,
}
/// Cache: code → resolved share bounds. We cache `None` too so an invalid
/// code doesn't keep hammering PocketBase on every request from a malicious
/// or stale client.
pub struct ShareBoundsCache {
entries: RwLock<FxHashMap<String, (Option<ShareBounds>, Instant)>>,
}
impl ShareBoundsCache {
pub fn new() -> Self {
Self {
entries: RwLock::new(FxHashMap::default()),
}
}
fn get(&self, code: &str) -> Option<Option<ShareBounds>> {
let map = self.entries.read();
if let Some((bounds, created)) = map.get(code) {
if created.elapsed().as_secs() < SHARE_CACHE_TTL_SECS {
return Some(*bounds);
}
}
None
}
fn insert(&self, code: String, bounds: Option<ShareBounds>) {
let mut map = self.entries.write();
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
let now = Instant::now();
map.retain(|_, (_, created)| {
now.duration_since(*created).as_secs() < SHARE_CACHE_TTL_SECS
});
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
let mut ages: Vec<Instant> = map.values().map(|(_, c)| *c).collect();
ages.sort();
let median = ages[ages.len() / 2];
map.retain(|_, (_, created)| *created >= median);
}
}
map.insert(code, (bounds, Instant::now()));
}
}
impl Default for ShareBoundsCache {
fn default() -> Self {
Self::new()
}
}
/// 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.
pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
if !is_valid_share_code(code) {
return None;
}
if let Some(cached) = state.share_cache.get(code) {
return cached;
}
let resolved = fetch_share_bounds(state, code).await;
state.share_cache.insert(code.to_string(), resolved);
resolved
}
/// Convenience: resolve `Option<&str>` share code → `Option<ShareBounds>`.
/// Skips the lookup entirely (and never touches the cache) when no code is
/// supplied or the supplied code is empty.
pub async fn resolve_share_code(state: &AppState, code: Option<&str>) -> Option<ShareBounds> {
match code {
Some(c) if !c.is_empty() => lookup_share_bounds(state, c).await,
_ => None,
}
}
fn is_valid_share_code(code: &str) -> bool {
!code.is_empty() && code.len() <= 20 && code.bytes().all(|b| b.is_ascii_alphanumeric())
}
async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
let token = match get_superuser_token(state).await {
Ok(t) => t,
Err(err) => {
warn!("share bounds lookup: superuser auth failed: {err}");
return None;
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("code=\"{code}\"");
let url = format!(
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
.ok()?;
if !resp.status().is_success() {
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))
}
/// 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)> {
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(),
_ => {}
}
}
Some((lat?, lon?, zoom?))
}
/// Derive the share bbox from the share's center lat/lon and zoom.
///
/// A viewport W pixels wide at zoom z covers `W * 360 / (256 * 2^z)` degrees
/// of longitude. For a typical 1280px-wide desktop viewport that's roughly
/// `1800 / 2^z` degrees — we use that as the half-width, so the bbox is
/// ~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);
ShareBounds {
south: lat - half_lat,
north: lat + half_lat,
west: lon - half_lon,
east: lon + half_lon,
}
}
/// 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 if bounds exceed the free zone.
/// Free/anonymous users get 403 unless the bounds fall inside the free zone
/// or inside the bbox granted by a valid share code.
#[allow(clippy::result_large_err)]
pub fn check_license_bounds(
user: &Option<PocketBaseUser>,
bounds: (f64, f64, f64, f64),
share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> {
if let Some(u) = user {
if u.is_admin || u.subscription == "licensed" {
@ -26,6 +198,12 @@ pub fn check_license_bounds(
return Ok(());
}
if let Some(sb) = share_bounds {
if south >= sb.south && west >= sb.west && north <= sb.north && east <= sb.east {
return Ok(());
}
}
let body = json!({
"error": "license_required",
"message": "A license is required to view data outside the demo area",
@ -46,6 +224,7 @@ pub fn check_license_point(
user: &Option<PocketBaseUser>,
lat: f64,
lon: f64,
share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> {
check_license_bounds(user, (lat, lon, lat, lon))
check_license_bounds(user, (lat, lon, lat, lon), share_bounds)
}

View file

@ -387,6 +387,7 @@ async fn main() -> anyhow::Result<()> {
let token_cache = Arc::new(auth::TokenCache::new());
let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
let share_cache = Arc::new(licensing::ShareBoundsCache::new());
let app_state = AppState {
data: property_data,
@ -416,6 +417,7 @@ async fn main() -> anyhow::Result<()> {
travel_time_store,
token_cache,
superuser_token_cache,
share_cache,
ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key,
stripe_secret_key: cli.stripe_secret_key,
@ -498,7 +500,10 @@ async fn main() -> anyhow::Result<()> {
post(routes::post_ai_filters).layer(ConcurrencyLimitLayer::new(5)),
)
.route("/api/streetview", get(routes::get_streetview))
.route("/api/newsletter", patch(routes::patch_newsletter))
.route(
"/api/newsletter",
patch(routes::patch_newsletter).layer(ConcurrencyLimitLayer::new(10)),
)
.route("/api/pricing", get(routes::get_pricing))
.route(
"/api/checkout",
@ -512,7 +517,10 @@ async fn main() -> anyhow::Result<()> {
.route("/api/invite/{code}", get(routes::get_invite))
.route("/api/redeem-invite", post(routes::post_redeem_invite))
.route("/s/{code}", get(routes::get_short_url))
.route("/api/telemetry", post(routes::post_telemetry))
.route(
"/api/telemetry",
post(routes::post_telemetry).layer(ConcurrencyLimitLayer::new(20)),
)
.route(
"/pb/{*rest}",
any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)),

View file

@ -11,6 +11,21 @@ use crate::state::AppState;
const OG_PLACEHOLDER: &str =
r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
/// 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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}
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
@ -46,32 +61,34 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
None => return response,
};
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot)
// 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!("{}/api/screenshot?og=1&path={}", state.public_url, path)
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}")
} else {
format!(
"{}/api/screenshot?og=1&path={}&{}",
state.public_url, path, query_string
)
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}&amp;{query_e}")
}
} else if query_string.is_empty() {
format!("{}/api/screenshot?og=1", state.public_url)
format!("{public_url_e}/api/screenshot?og=1")
} else {
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
format!("{public_url_e}/api/screenshot?og=1&amp;{query_e}")
};
let og_url = if query_string.is_empty() {
format!("{}{}", state.public_url, path)
format!("{public_url_e}{path_e}")
} else {
format!("{}{}?{}", state.public_url, path, query_string)
format!("{public_url_e}{path_e}?{query_e}")
};
let og_logo = format!("{}/favicon.svg", state.public_url);
let og_logo = format!("{public_url_e}/favicon.svg");
let (og_title, og_description) = if is_invite {
(

View file

@ -86,6 +86,13 @@ pub fn parse_filters(
.trim()
.parse::<f32>()
.map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?;
// Reject inverted ranges: keeps the selectivity sort key (max - min) non-negative
// and surfaces user error instead of silently returning zero rows.
if min.is_finite() && max.is_finite() && min > max {
return Err(format!(
"Numeric filter '{name}' has inverted range: min ({min}) > max ({max})"
));
}
numeric.push(ParsedFilter {
feat_idx,
min_u16: quant.encode_min(feat_idx, min),
@ -94,8 +101,10 @@ pub fn parse_filters(
}
}
// Sort by selectivity: more selective filters first for early rejection
numeric.sort_unstable_by_key(|f| f.max_u16.wrapping_sub(f.min_u16));
// Sort by selectivity: more selective filters first for early rejection.
// Use saturating_sub so a hypothetical inverted range (min > max) yields 0
// rather than wrapping to a huge u16 and corrupting the sort.
numeric.sort_unstable_by_key(|f| f.max_u16.saturating_sub(f.min_u16));
enums.sort_unstable_by_key(|f| f.allowed.len());
Ok((numeric, enums))
@ -418,6 +427,67 @@ mod tests {
assert_eq!(enums.len(), 1);
}
#[test]
fn parse_filters_rejects_inverted_range() {
// Inverted bounds (min > max) would previously produce a wrapping
// selectivity key in the sort and silently match zero rows. Now we
// reject them at parse time with a clear error.
let tq = test_quant(3, 2);
let result = parse_filters(
Some("price:500:100"),
&feature_name_to_index(),
&enum_values(),
&tq.as_ref(),
);
assert!(result.is_err(), "expected inverted range to be rejected");
let err = result.unwrap_err();
assert!(err.contains("inverted range"), "got: {err}");
assert!(err.contains("price"), "got: {err}");
}
#[test]
fn parse_filters_accepts_equal_min_max() {
// min == max is a valid (degenerate) range and must still be accepted.
let tq = test_quant(3, 2);
let (numeric, _) = parse_filters(
Some("price:200:200"),
&feature_name_to_index(),
&enum_values(),
&tq.as_ref(),
)
.unwrap();
assert_eq!(numeric.len(), 1);
}
#[test]
fn selectivity_sort_handles_saturating_sub() {
// Even if a ParsedFilter is constructed directly with min_u16 > max_u16
// (bypassing parse_filters validation), the sort must not wrap to a
// huge u16 — saturating_sub clamps to 0.
let mut filters = [
ParsedFilter {
feat_idx: 0,
min_u16: 1000,
max_u16: 2000, // range = 1000
},
ParsedFilter {
feat_idx: 1,
min_u16: 5000,
max_u16: 100, // inverted; saturating gives 0 (most "selective")
},
ParsedFilter {
feat_idx: 2,
min_u16: 0,
max_u16: 500, // range = 500
},
];
filters.sort_unstable_by_key(|f| f.max_u16.saturating_sub(f.min_u16));
// Sorted ascending by saturating range: inverted (0), then 500, then 1000.
assert_eq!(filters[0].feat_idx, 1);
assert_eq!(filters[1].feat_idx, 2);
assert_eq!(filters[2].feat_idx, 0);
}
#[test]
fn parse_invalid_numeric_format_errors() {
let tq = test_quant(4, 2);
@ -627,8 +697,7 @@ mod tests {
},
];
let (total, impacts) =
count_filter_impacts(&filters, &[], &feature_data, 2, (0..4u32).into_iter());
let (total, impacts) = count_filter_impacts(&filters, &[], &feature_data, 2, 0..4u32);
assert_eq!(total, 1); // only row 0 passes
assert_eq!(impacts[0], 1); // row 1 fails price only
@ -661,13 +730,8 @@ mod tests {
allowed: [0u16, 1].into_iter().collect(),
}];
let (total, impacts) = count_filter_impacts(
&num_filters,
&enum_filters,
&feature_data,
2,
(0..3u32).into_iter(),
);
let (total, impacts) =
count_filter_impacts(&num_filters, &enum_filters, &feature_data, 2, 0..3u32);
assert_eq!(total, 1); // row 0
assert_eq!(impacts[0], 1); // row 2 fails numeric only → impacts[0]
@ -678,8 +742,7 @@ mod tests {
fn filter_impacts_no_filters() {
let tq = test_quant(1, 1);
let feature_data = vec![tq.encode(0, 100.0)];
let (total, impacts) =
count_filter_impacts(&[], &[], &feature_data, 1, (0..1u32).into_iter());
let (total, impacts) = count_filter_impacts(&[], &[], &feature_data, 1, 0..1u32);
assert_eq!(total, 1);
assert!(impacts.is_empty());
}

View file

@ -135,7 +135,7 @@ pub async fn get_export(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))?;
check_license_bounds(&user.0, (south, west, north, east), None)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::check_license_bounds;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters,
row_passes_filters, validate_h3_resolution,
@ -76,6 +76,8 @@ pub struct HexagonStatsParams {
/// shortest travel time for this mode+slug (so it has journey data).
pub journey_mode: Option<String>,
pub journey_slug: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
pub async fn get_hexagon_stats(
@ -99,7 +101,8 @@ pub async fn get_hexagon_stats(
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, h3_bounds, share_bounds)?;
let h3_str = params.h3;
let quant = state.data.quant_ref();

View file

@ -15,7 +15,7 @@ use crate::aggregation::{Aggregator, EnumDistConfig};
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, needs_parent, parse_enum_dist, parse_field_indices, parse_filters,
require_bounds, row_passes_filters, validate_h3_resolution,
@ -68,6 +68,8 @@ pub struct HexagonParams {
/// Feature name for enum distribution counting (pie chart visualization).
/// When set, each cell includes `dist_{name}: [count_val0, count_val1, ...]`.
enum_dist: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
share: Option<String>,
}
/// Build feature maps from aggregated cell data, filtering to only cells whose
@ -203,7 +205,8 @@ pub async fn get_hexagons(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -9,7 +9,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
use crate::licensing::check_license_point;
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_filters, row_passes_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
@ -22,6 +22,8 @@ pub struct PostcodePropertiesParams {
pub filters: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
pub async fn get_postcode_properties(
@ -45,7 +47,13 @@ pub async fn get_postcode_properties(
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_point(
&user.0,
centroid_lat as f64,
centroid_lon as f64,
share_bounds,
)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -9,7 +9,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::check_license_point;
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
@ -24,6 +24,8 @@ pub struct PostcodeStatsParams {
/// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned.
pub fields: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
pub async fn get_postcode_stats(
@ -49,7 +51,13 @@ pub async fn get_postcode_stats(
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
// License check using postcode centroid
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_point(
&user.0,
centroid_lat as f64,
centroid_lon as f64,
share_bounds,
)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -14,7 +14,7 @@ use crate::aggregation::{Aggregator, EnumDistConfig};
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
bounds_intersect, parse_enum_dist, parse_field_indices, parse_filters, require_bounds,
row_passes_filters,
@ -47,6 +47,8 @@ pub struct PostcodeParams {
travel: Option<String>,
/// Feature name for enum distribution counting (pie chart visualization).
enum_dist: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
share: Option<String>,
}
pub async fn get_postcodes(
@ -58,7 +60,8 @@ pub async fn get_postcodes(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -10,9 +10,10 @@ use tracing::warn;
use crate::pocketbase::get_superuser_token;
use crate::state::{AppState, SharedState};
/// Pricing tiers: (cumulative user cap, price in pence).
const TIERS: &[(u64, u64)] = &[(5, 99), (15, 999), (30, 2999), (50, 4999)];
/// Pricing tiers: (cumulative public user cap, price in pence).
const TIERS: &[(u64, u64)] = &[(50, 99), (150, 999), (250, 2999), (350, 4999)];
const FINAL_PRICE_PENCE: u64 = 9999;
const PUBLIC_LAUNCH_USER_OFFSET: u64 = 120;
#[derive(Serialize)]
pub struct Tier {
@ -28,16 +29,24 @@ pub struct PricingResponse {
tiers: Vec<Tier>,
}
/// Determine the price (in pence) for the next user given `count` existing licensed users.
pub fn price_for_count(count: u64) -> u64 {
fn public_licensed_count(actual_count: u64) -> u64 {
actual_count.saturating_add(PUBLIC_LAUNCH_USER_OFFSET)
}
fn price_for_public_count(public_count: u64) -> u64 {
for &(cap, price) in TIERS {
if count < cap {
if public_count < cap {
return price;
}
}
FINAL_PRICE_PENCE
}
/// Determine the price (in pence) for the next user given the real licensed user count.
pub fn price_for_count(count: u64) -> u64 {
price_for_public_count(public_licensed_count(count))
}
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let token = get_superuser_token(state).await?;
@ -75,7 +84,8 @@ pub async fn get_pricing(State(shared): State<Arc<SharedState>>) -> Response {
}
};
let current_price = price_for_count(count);
let public_count = public_licensed_count(count);
let current_price = price_for_public_count(public_count);
let mut tiers = Vec::new();
let mut prev_cap = 0u64;
@ -94,9 +104,44 @@ pub async fn get_pricing(State(shared): State<Arc<SharedState>>) -> Response {
});
Json(PricingResponse {
licensed_count: count,
licensed_count: public_count,
current_price_pence: current_price,
tiers,
})
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_count_starts_at_launch_offset() {
assert_eq!(public_licensed_count(0), 120);
assert_eq!(public_licensed_count(7), 127);
}
#[test]
fn price_for_count_uses_public_tier_position() {
assert_eq!(price_for_count(0), 999);
assert_eq!(price_for_count(29), 999);
assert_eq!(price_for_count(30), 2999);
assert_eq!(price_for_count(129), 2999);
assert_eq!(price_for_count(130), 4999);
assert_eq!(price_for_count(229), 4999);
assert_eq!(price_for_count(230), 9999);
}
#[test]
fn price_for_public_count_uses_tier_caps() {
assert_eq!(price_for_public_count(0), 99);
assert_eq!(price_for_public_count(49), 99);
assert_eq!(price_for_public_count(50), 999);
assert_eq!(price_for_public_count(149), 999);
assert_eq!(price_for_public_count(150), 2999);
assert_eq!(price_for_public_count(249), 2999);
assert_eq!(price_for_public_count(250), 4999);
assert_eq!(price_for_public_count(349), 4999);
assert_eq!(price_for_public_count(350), 9999);
}
}

View file

@ -12,7 +12,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
use crate::data::RenovationEvent;
use crate::licensing::check_license_bounds;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
validate_h3_resolution,
@ -26,6 +26,8 @@ pub struct HexagonPropertiesParams {
pub filters: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
#[derive(Serialize)]
@ -187,7 +189,8 @@ pub async fn get_hexagon_properties(
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, h3_bounds, share_bounds)?;
let h3_str = params.h3;
let quant = state.data.quant_ref();

View file

@ -139,7 +139,11 @@ pub async fn get_short_url(
match params {
Some(params) => {
let redirect_url = format!("/dashboard?{params}");
let redirect_url = if params.is_empty() {
format!("/dashboard?share={code}")
} else {
format!("/dashboard?{params}&share={code}")
};
let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url);
let og_url = format!("{}/s/{code}", state.public_url);
let og_title = "Perfect Postcode | Every neighbourhood in England";

View file

@ -1,10 +1,13 @@
use std::sync::Arc;
use std::collections::VecDeque;
use std::sync::{Arc, LazyLock};
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use hmac::{Hmac, Mac};
use parking_lot::Mutex;
use rustc_hash::FxHashSet;
use sha2::Sha256;
use tracing::{info, warn};
@ -13,6 +16,46 @@ use crate::state::SharedState;
type HmacSha256 = Hmac<Sha256>;
/// Process-local LRU of recently processed Stripe event IDs.
/// Stripe retries deliver the same event ID; we drop duplicates so we don't
/// re-run side effects (subscription writes, token cache invalidation, logs).
/// Capacity is intentionally generous: at typical webhook volumes this covers
/// far more than Stripe's retry window.
struct EventDedup {
seen: FxHashSet<String>,
queue: VecDeque<String>,
capacity: usize,
}
impl EventDedup {
fn new(capacity: usize) -> Self {
Self {
seen: FxHashSet::default(),
queue: VecDeque::with_capacity(capacity),
capacity,
}
}
/// Returns `true` if this event ID is new (and records it),
/// `false` if it was already seen recently.
fn check_and_insert(&mut self, id: &str) -> bool {
if self.seen.contains(id) {
return false;
}
self.seen.insert(id.to_string());
self.queue.push_back(id.to_string());
if self.queue.len() > self.capacity {
if let Some(old) = self.queue.pop_front() {
self.seen.remove(&old);
}
}
true
}
}
static EVENT_DEDUP: LazyLock<Mutex<EventDedup>> =
LazyLock::new(|| Mutex::new(EventDedup::new(1024)));
/// Verify Stripe webhook signature (v1 scheme).
fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
// Parse timestamp and signature from header: "t=TIMESTAMP,v1=SIGNATURE"
@ -95,7 +138,16 @@ pub async fn post_stripe_webhook(
};
let event_type = event["type"].as_str().unwrap_or("");
info!(event_type, "Received Stripe webhook");
let event_id = event["id"].as_str().unwrap_or("");
// Idempotency: drop replays/retries of an already-processed event.
// We always answer 200 so Stripe stops retrying.
if !event_id.is_empty() && !EVENT_DEDUP.lock().check_and_insert(event_id) {
info!(event_id, event_type, "Dropping duplicate Stripe webhook");
return StatusCode::OK.into_response();
}
info!(event_id, event_type, "Received Stripe webhook");
if event_type == "checkout.session.completed" {
let user_id = event["data"]["object"]["client_reference_id"]

View file

@ -7,6 +7,7 @@ use crate::auth::TokenCache;
use crate::data::{
OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
};
use crate::licensing::ShareBoundsCache;
use crate::pocketbase::SuperuserTokenCache;
use crate::routes::FeaturesResponse;
use crate::utils::GridIndex;
@ -45,6 +46,9 @@ pub struct AppState {
pub token_cache: Arc<TokenCache>,
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
pub superuser_token_cache: Arc<SuperuserTokenCache>,
/// Cached share-link bbox lookups (5min TTL); used to grant unlicensed
/// users access to the area their share link references.
pub share_cache: Arc<ShareBoundsCache>,
// --- Config (cheap to clone) ---
/// URL of the screenshot service (e.g. http://screenshot:8002)