lgtm
Some checks failed
CI / Check (push) Failing after 2m47s
Build and publish Docker image / build-and-push (push) Successful in 5m37s

This commit is contained in:
Andras Schmelczer 2026-06-26 16:48:20 +01:00
parent 5e73287eaf
commit e2b85fe819
73 changed files with 1180 additions and 2028 deletions

View file

@ -32,22 +32,16 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000;
/// Timeout for outbound HTTP service calls (seconds).
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Half-spans of the demo free zone box, in degrees. The box is built this wide
/// around the visitor's approximate (IP-derived) location — or the fallback
/// centre below when the IP can't be geolocated. Sized to match the original
/// inner-London "zone 1" box (≈0.15° lat × 0.36° lon).
pub const FREE_ZONE_HALF_LAT: f64 = 0.075;
pub const FREE_ZONE_HALF_LON: f64 = 0.18;
/// Fallback demo free-zone centre (Canary Wharf), used when the visitor hasn't
/// supplied a demo centre — i.e. they declined the "check your area" prompt or no
/// GPS fix was available. When they consent, the browser sends their own centre.
pub const FREE_ZONE_FALLBACK_LAT: f64 = 51.5054;
pub const FREE_ZONE_FALLBACK_LON: f64 = -0.0235;
/// Demo (unlicensed) users may apply at most this many filters. Enforced both
/// client-side (UX) and server-side (anti-DoS); must match the frontend constant.
pub const DEMO_MAX_FILTERS: usize = 5;
/// Anonymous (logged-out) users may apply at most this many filters at a time. This
/// is the only gate on the demo: there is no region lock — they get the full
/// dashboard everywhere, just capped on simultaneous filters. Enforced both
/// client-side (UX) and server-side (defense-in-depth); must match the frontend
/// constant `DEMO_MAX_FILTERS`.
pub const DEMO_MAX_FILTERS: usize = 3;
/// Registered but non-paying accounts get a higher filter allowance than anonymous
/// visitors — the incentive to create a free account. Paying/licensed users are
/// uncapped. Must match the frontend constant `REGISTERED_MAX_FILTERS`.
pub const REGISTERED_MAX_FILTERS: usize = 5;
/// Sliding-window rate limit for unlicensed traffic to `/api/` endpoints, keyed by
/// account id (preferred) or client IP. Generous enough for active demo use, tight
/// enough to blunt tile-by-tile scraping. Licensed users/admins are exempt.
@ -55,10 +49,3 @@ pub const DEMO_RATE_LIMIT_MAX: usize = 600;
pub const DEMO_RATE_LIMIT_WINDOW_SECS: u64 = 60;
/// Soft cap on tracked rate-limit keys before stale ones are pruned.
pub const DEMO_RATE_LIMIT_MAX_KEYS: usize = 50_000;
pub const SHARE_CACHE_TTL_SECS: u64 = 300;
pub const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
pub const MIN_SHARE_ZOOM: f64 = 11.0;
pub const MAX_SHARE_ZOOM: f64 = 20.0;
pub const MAX_SHARE_LAT_SPAN: f64 = 1.2;
pub const MAX_SHARE_LON_SPAN: f64 = 2.0;

View file

@ -126,7 +126,7 @@ impl CrimeRecords {
continue;
};
for i in start..start + count {
if since_month.map_or(true, |s| month[i as usize] >= s) {
if since_month.is_none_or(|s| month[i as usize] >= s) {
out.push(i);
}
}
@ -518,7 +518,7 @@ mod tests {
rss_after,
);
assert!(recs.by_postcode.len() > 0, "expected at least one postcode");
assert!(!recs.by_postcode.is_empty(), "expected at least one postcode");
assert!(total > 0, "expected at least one record");
// The old `.collect()` decoded all rows' string columns at once (tens of
// GB). Streaming must keep the peak growth far below that; a generous 20GiB

View file

@ -55,6 +55,7 @@ const DASHBOARD_POI_GROUPS: &[(&str, &[&str])] = &[
&[
"Rail station",
"Tube station",
"DLR station",
"Tram & Metro stop",
"Bus station",
"Bus stop",

View file

@ -62,7 +62,7 @@ impl PostcodePopulation {
let mut by_postcode: FxHashMap<String, u32> = FxHashMap::default();
by_postcode.reserve(df.height());
for (postcode, population) in postcode_col.into_iter().zip(population_col.into_iter()) {
for (postcode, population) in postcode_col.into_iter().zip(population_col) {
let (Some(postcode), Some(population)) = (postcode, population) else {
continue;
};

View file

@ -1,127 +0,0 @@
//! Per-visitor demo "free zone": the small area an unlicensed visitor may explore.
//!
//! The visitor's browser chooses the centre — their GPS location (with consent) or
//! a Canary Wharf default — and sends it on each request as `demoLat`/`demoLon`
//! query params. We build a fixed-size box around that centre for the licensing
//! check. Absent/invalid params fall back to Canary Wharf.
//!
//! The centre is client-supplied and therefore spoofable: this is a soft demo gate
//! backed by the rate-limit / filter-cap guards in `ratelimit.rs`, not a hard
//! security boundary. (We deliberately removed IP-based geolocation in favour of
//! this explicit, consent-based flow.)
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use serde::Serialize;
use url::form_urlencoded;
use crate::consts::{
FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON, FREE_ZONE_HALF_LAT, FREE_ZONE_HALF_LON,
};
/// A demo free-zone bounding box (degrees).
#[derive(Clone, Copy, Debug, Serialize)]
pub struct FreeZone {
pub south: f64,
pub west: f64,
pub north: f64,
pub east: f64,
}
/// A visitor's resolved demo zone, inserted into request extensions by
/// [`demo_zone_middleware`] for every request.
#[derive(Clone, Copy, Debug)]
pub struct DemoZone {
pub free_zone: FreeZone,
}
/// Build the demo free-zone box centred on `(lat, lon)`, keeping a fixed size and
/// clamping to valid lat/lon ranges.
pub fn free_zone_around(lat: f64, lon: f64) -> FreeZone {
FreeZone {
south: (lat - FREE_ZONE_HALF_LAT).max(-90.0),
north: (lat + FREE_ZONE_HALF_LAT).min(90.0),
west: (lon - FREE_ZONE_HALF_LON).max(-180.0),
east: (lon + FREE_ZONE_HALF_LON).min(180.0),
}
}
/// Read the visitor's chosen demo centre from the `demoLat`/`demoLon` query params.
fn demo_center_from_query(query: Option<&str>) -> Option<(f64, f64)> {
let query = query?;
let mut lat: Option<f64> = None;
let mut lon: Option<f64> = None;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"demoLat" => lat = value.parse().ok(),
"demoLon" => lon = value.parse().ok(),
_ => {}
}
}
let (lat, lon) = (lat?, lon?);
if lat.is_finite()
&& lon.is_finite()
&& (-90.0..=90.0).contains(&lat)
&& (-180.0..=180.0).contains(&lon)
{
Some((lat, lon))
} else {
None
}
}
/// Resolve a request's demo zone from its query params, falling back to Canary Wharf.
pub fn resolve_demo_zone(query: Option<&str>) -> DemoZone {
let (lat, lon) =
demo_center_from_query(query).unwrap_or((FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON));
DemoZone {
free_zone: free_zone_around(lat, lon),
}
}
/// Middleware that resolves the visitor's [`DemoZone`] and stashes it in request
/// extensions, mirroring how `auth_middleware` provides `OptionalUser`.
pub async fn demo_zone_middleware(req: Request, next: Next) -> Response {
let zone = resolve_demo_zone(req.uri().query());
let (mut parts, body) = req.into_parts();
parts.extensions.insert(zone);
next.run(Request::from_parts(parts, body)).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fallback_box_is_canary_wharf_sized_and_centred() {
let fz = resolve_demo_zone(None).free_zone;
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
assert!((((fz.west + fz.east) / 2.0) - FREE_ZONE_FALLBACK_LON).abs() < 1e-9);
}
#[test]
fn reads_client_supplied_centre() {
let fz = resolve_demo_zone(Some("demoLat=53.4808&demoLon=-2.2426&foo=bar")).free_zone;
assert!((((fz.south + fz.north) / 2.0) - 53.4808).abs() < 1e-9);
assert!((((fz.west + fz.east) / 2.0) - (-2.2426)).abs() < 1e-9);
}
#[test]
fn rejects_out_of_range_or_missing_coords() {
// Out of range → fallback.
let fz = resolve_demo_zone(Some("demoLat=999&demoLon=0")).free_zone;
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
// Only one coord → fallback.
let fz = resolve_demo_zone(Some("demoLat=53.0")).free_zone;
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
}
#[test]
fn box_keeps_a_constant_size_when_relocated() {
let a = free_zone_around(51.5, -0.1);
let b = free_zone_around(55.0, -3.0);
assert!(((a.north - a.south) - (b.north - b.south)).abs() < 1e-9);
assert!(((a.east - a.west) - (b.east - b.west)).abs() < 1e-9);
}
}

View file

@ -1,355 +0,0 @@
use std::time::Instant;
use axum::http::StatusCode;
use axum::response::IntoResponse;
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::{
MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM,
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS,
};
use crate::demo_zone::FreeZone;
use crate::pocketbase::get_superuser_token;
use crate::state::AppState;
#[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 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;
}
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 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)
}
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;
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()));
}
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.
///
/// 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(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).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 their free zone
/// (a box centred on their IP-approximate location) 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),
free_zone: FreeZone,
share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> {
if let Some(u) = user {
if u.is_admin || u.subscription == "licensed" {
return Ok(());
}
}
let (south, west, north, east) = bounds;
if south >= free_zone.south
&& west >= free_zone.west
&& north <= free_zone.north
&& east <= free_zone.east
{
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",
"free_zone": {
"south": free_zone.south,
"west": free_zone.west,
"north": free_zone.north,
"east": free_zone.east,
}
});
Err((StatusCode::FORBIDDEN, axum::Json(body)).into_response())
}
/// Convenience wrapper that takes a point (lat, lon) instead of bounds.
#[allow(clippy::result_large_err)]
pub fn check_license_point(
user: &Option<PocketBaseUser>,
lat: f64,
lon: f64,
free_zone: FreeZone,
share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> {
check_license_bounds(user, (lat, lon, lat, lon), free_zone, 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,10 +7,8 @@ mod bugsink;
mod checkout_sessions;
mod consts;
mod data;
mod demo_zone;
mod features;
mod language;
mod licensing;
mod metrics;
mod og_middleware;
pub mod parsing;
@ -701,7 +699,6 @@ 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 actual_listings = {
let path = &cli.actual_listings_path;
@ -806,7 +803,6 @@ async fn main() -> anyhow::Result<()> {
area_crime_averages,
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,
@ -1102,7 +1098,6 @@ async fn main() -> anyhow::Result<()> {
}
.layer(middleware::from_fn(metrics::track_metrics))
.layer(middleware::from_fn(ratelimit::demo_guard_middleware))
.layer(middleware::from_fn(demo_zone::demo_zone_middleware))
.layer(middleware::from_fn(auth::auth_middleware))
.layer(middleware::from_fn(
move |req: axum::extract::Request, next: middleware::Next| {

View file

@ -774,12 +774,6 @@ async fn ensure_short_urls_fields(
"click_count",
serde_json::json!({ "name": "click_count", "type": "number" }),
);
for field in ["share_south", "share_west", "share_north", "share_east"] {
add_field(
field,
serde_json::json!({ "name": field, "type": "number" }),
);
}
if new_fields.len() == fields.len() {
return Ok(());
@ -1229,10 +1223,6 @@ pub async fn ensure_collections(
Field::text("params", true),
Field::text("created_by", false),
Field::number("click_count"),
Field::number("share_south"),
Field::number("share_west"),
Field::number("share_north"),
Field::number("share_east"),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],

View file

@ -4,9 +4,9 @@
//! admins are exempt.
//!
//! This is a soft anti-abuse measure, not a hard security boundary: the rate-limit
//! key falls back to the (spoofable) client IP for anonymous users, and the demo
//! gate is best-effort by design (see demo_zone.rs). Logged-in free accounts are keyed
//! by their stable account id, which spoofing the IP header does not evade.
//! key falls back to the (spoofable) client IP for anonymous users. Logged-in free
//! accounts are keyed by their stable account id, which spoofing the IP header does
//! not evade.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
@ -26,6 +26,7 @@ use url::form_urlencoded;
use crate::auth::OptionalUser;
use crate::consts::{
DEMO_MAX_FILTERS, DEMO_RATE_LIMIT_MAX, DEMO_RATE_LIMIT_MAX_KEYS, DEMO_RATE_LIMIT_WINDOW_SECS,
REGISTERED_MAX_FILTERS,
};
use crate::state::AppState;
@ -123,26 +124,30 @@ fn is_internal_request(headers: &HeaderMap, peer: Option<IpAddr>) -> bool {
}
}
/// Count non-empty `;;`-separated entries in the `filters` query parameter.
/// Count a request's active filters: non-empty `;;`-separated entries in the
/// `filters` parameter PLUS non-empty `|`-separated entries in the `travel`
/// parameter. A travel-time destination restricts which properties match, so it
/// counts toward the cap just like any feature filter.
fn filter_count(query: &str) -> usize {
let mut count = 0;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if key == "filters" {
return value
.split(";;")
.filter(|entry| !entry.trim().is_empty())
.count();
match key.as_ref() {
"filters" => {
count += value
.split(";;")
.filter(|entry| !entry.trim().is_empty())
.count();
}
"travel" => {
count += value
.split('|')
.filter(|entry| !entry.trim().is_empty())
.count();
}
_ => {}
}
}
0
}
/// Whether the request carries a non-empty `share` code. Such requests view a shared
/// dashboard whose saved filter set may legitimately exceed the demo cap, so they're
/// exempt from the filter cap — the actual share-bounds authorization still runs in
/// the handler.
fn has_share_code(query: &str) -> bool {
form_urlencoded::parse(query.as_bytes())
.any(|(key, value)| key == "share" && !value.trim().is_empty())
count
}
/// Middleware applying the demo filter cap and rate limit to unlicensed `/api/`
@ -180,16 +185,27 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response {
return next.run(req).await;
}
// Server-side filter cap (mirrors the client demo limit; blunts oversized
// aggregation requests). Share recipients are exempt — their saved view may
// legitimately carry >5 filters and the share grant is checked in the handler.
// Server-side filter cap (mirrors the client-side limit; blunts oversized
// aggregation requests). With the region lock removed this is the only gate on
// non-paying users, so it applies to every unlicensed data request. Registered
// (logged-in) free accounts get a higher allowance than anonymous visitors.
let filter_limit = if user.is_some() {
REGISTERED_MAX_FILTERS
} else {
DEMO_MAX_FILTERS
};
if let Some(query) = req.uri().query() {
if !has_share_code(query) && filter_count(query) > DEMO_MAX_FILTERS {
if filter_count(query) > filter_limit {
let message = if user.is_some() {
format!("Free accounts are limited to {filter_limit} filters at a time — upgrade for unlimited")
} else {
format!("Create a free account for up to {REGISTERED_MAX_FILTERS} filters (currently limited to {filter_limit})")
};
return (
StatusCode::BAD_REQUEST,
axum::Json(json!({
"error": "filter_limit",
"message": format!("The demo is limited to {DEMO_MAX_FILTERS} filters"),
"message": message,
})),
)
.into_response();
@ -237,15 +253,14 @@ mod tests {
);
// Trailing/empty entries are ignored.
assert_eq!(filter_count("filters=price%3A1%3A2%3B%3B"), 1);
}
#[test]
fn detects_share_code() {
assert!(!has_share_code(""));
assert!(!has_share_code("filters=a%3B%3Bb"));
assert!(!has_share_code("share=")); // empty value doesn't count
assert!(has_share_code("share=abc123"));
assert!(has_share_code("bounds=1,2,3,4&share=xyz&filters=a"));
// Travel-time destinations (`|`-separated) count as filters too, and add
// to the feature-filter count.
assert_eq!(filter_count("travel=car%3Abank"), 1);
assert_eq!(filter_count("travel=car%3Abank%7Ctransit%3Akings"), 2);
assert_eq!(
filter_count("filters=price%3A1%3A2&travel=car%3Abank%7Ctransit%3Akings"),
3
);
}
#[test]

View file

@ -11,7 +11,6 @@ use crate::api_error::ApiError;
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::ActualListing;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
parse_filters_with_poi, require_bounds, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
};
@ -28,8 +27,6 @@ pub struct ActualListingsParams {
travel: Option<String>,
/// Number of results to skip. Defaults to 0.
offset: Option<usize>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
share: Option<String>,
}
#[derive(Serialize)]
@ -55,7 +52,6 @@ const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
pub async fn get_actual_listings(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<ActualListingsParams>,
) -> Result<Json<ActualListingsResponse>, Response> {
let state = shared.load_state();
@ -74,14 +70,6 @@ pub async fn get_actual_listings(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(
&user.0,
(south, west, north, east),
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(

View file

@ -64,12 +64,22 @@ fn backend_filter_name(name: &str) -> Option<String> {
return Some(feature_name.to_string());
}
// These must stay in lock-step with the frontend's synthetic filter-key
// prefixes (the `*_FILTER_KEY_PREFIX` / `*_FILTER_NAME` constants in
// frontend/src/lib/*-filter.ts). A missing or stale prefix means a filter
// of that kind in the conversation `context` is sent to the model as an
// opaque key instead of its real feature name, so the model can't see it
// (and silently drops it if it echoes the key back).
for prefix in [
"Specific crimes:",
"Serious crime:",
"Minor crime:",
"Political vote share:",
"Ethnicities:",
"Qualifications:",
"Tenure:",
"Amenity distance:",
"Transport distance:",
"Closest transport option:",
"Amenities within 2km:",
"Amenities within 5km:",
] {
@ -343,12 +353,32 @@ mod tests {
canonical_filter_name("Political vote share:%25%20Labour:0"),
"% Labour"
);
// POI distance for transport keys on the "Closest transport option"
// filter name, not the long-gone "Transport distance".
assert_eq!(
canonical_filter_name(
"Transport distance:Distance%20to%20nearest%20amenity%20%28Bus%20stop%29%20%28km%29:0"
"Closest transport option:Distance%20to%20nearest%20amenity%20%28Bus%20stop%29%20%28km%29:0"
),
"Distance to nearest amenity (Bus stop) (km)"
);
// Census composition filters (qualifications, tenure) and the crime
// severity rollups fold into variant cards too and must round-trip.
assert_eq!(
canonical_filter_name("Qualifications:%25%20Degree%20or%20higher:0"),
"% Degree or higher"
);
assert_eq!(
canonical_filter_name("Tenure:%25%20Owner%20occupied:0"),
"% Owner occupied"
);
assert_eq!(
canonical_filter_name("Serious crime:Serious%20crime%20(%2Fyr%2C%207y):0"),
"Serious crime (/yr, 7y)"
);
assert_eq!(
canonical_filter_name("Minor crime:Minor%20crime%20(%2Fyr%2C%202y):0"),
"Minor crime (/yr, 2y)"
);
}
#[test]

View file

@ -37,6 +37,12 @@ pub fn build_system_prompt(
- Politics/elections are normal filters in the Neighbours group. Use exact vote share \
features such as % Labour, % Conservative, % Liberal Democrat, % Reform UK, % Green, \
% Other parties, or Voter turnout (%) when the user asks for political character.\n\
- Housing tenure is a normal filter in the Neighbours group. \"lots of homeowners\" / \
\"owner-occupied area\" = high % Owner occupied; \"rental area\" / \"lots of renters\" = \
high % Private rent; \"social housing\" / \"council housing\" = high % Social rent.\n\
- Education level is a normal filter in the Neighbours group. \"well-educated\" / \
\"graduate\" / \"professional\" / \"university-educated area\" = high % Degree or higher; \
\"few qualifications\" = high % No qualifications.\n\
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
@ -243,6 +249,17 @@ pub fn build_system_prompt(
.to_string(),
);
parts.push(
"\nUser: \"well-educated owner-occupier area, low crime, under 600k\"\n\
Output: {\"numeric_filters\": [\
{\"name\": \"Estimated current price\", \"bound\": \"max\", \"value\": 600000}, \
{\"name\": \"% Degree or higher\", \"bound\": \"min\", \"value\": 40}, \
{\"name\": \"% Owner occupied\", \"bound\": \"min\", \"value\": 60}, \
{\"name\": \"Serious crime (/yr, 7y)\", \"bound\": \"max\", \"value\": 5}], \
\"enum_filters\": [], \"travel_time_filters\": [], \"notes\": \"\"}"
.to_string(),
);
// Examples showing rent and price features
parts.push(
"\nUser: \"2 bed flat with rent under £1500/month\"\n\

View file

@ -9,13 +9,10 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::{check_license_bounds, check_license_point, resolve_share_code};
use crate::parsing::{cell_for_row_cached, h3_cell_bounds, needs_parent, validate_h3_resolution};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
@ -36,8 +33,6 @@ pub struct CrimeRecordsParams {
/// Lower bound on `month_index` (`year*12 + month0`) to restrict to a recent
/// window; omitted = all stored records (last 7 years).
pub since: Option<u32>,
/// Share-link code; grants scoped access for unlicensed users.
pub share: Option<String>,
}
#[derive(Serialize)]
@ -70,18 +65,14 @@ fn format_month(month_index: u32) -> String {
pub async fn get_crime_records(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<CrimeRecordsParams>,
) -> Result<Json<CrimeRecordsResponse>, axum::response::Response> {
let state = shared.load_state();
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let since = params.since;
// Resolve the selection to a set of postcodes, after a license check scoped
// to the selection's geometry (bounds for a hexagon, point for a postcode).
// Resolve the selection to a set of postcodes.
enum Selection {
Hexagon { cell: u64, resolution: u8 },
Postcode(String),
@ -96,23 +87,20 @@ pub async fn get_crime_records(
(StatusCode::BAD_REQUEST, "resolution is required with h3").into_response()
})?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
let bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, bounds, geo.free_zone, share_bounds)?;
Selection::Hexagon {
cell: cell.into(),
resolution,
}
} else if let Some(postcode) = params.postcode.clone() {
let normalized = normalize_postcode(&postcode);
let &pc_idx = state
// Validate the postcode exists (404 otherwise).
state
.postcode_data
.postcode_to_idx
.get(&normalized)
.ok_or_else(|| {
(StatusCode::NOT_FOUND, format!("Postcode not found: {normalized}")).into_response()
})?;
let (lat, lon) = state.postcode_data.centroids[pc_idx];
check_license_point(&user.0, lat as f64, lon as f64, geo.free_zone, share_bounds)?;
Selection::Postcode(normalized)
} else {
return Err((StatusCode::BAD_REQUEST, "h3 or postcode is required").into_response());

View file

@ -2,13 +2,10 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Json, Response};
use axum::Extension;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::auth::OptionalUser;
use crate::data::DevelopmentSite;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::require_bounds;
use crate::state::SharedState;
@ -19,8 +16,6 @@ const DEVELOPMENTS_LIMIT: usize = 4000;
#[derive(Deserialize)]
pub struct DevelopmentsParams {
bounds: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
share: Option<String>,
}
#[derive(Serialize)]
@ -32,12 +27,9 @@ pub struct DevelopmentsResponse {
/// Forward-looking "where new homes are coming" layer: planned/pipeline
/// development sites (MHCLG Brownfield Land register + Homes England Land Hub),
/// served as points within a viewport. Public OGL data, but still gated by the
/// normal demo/licence bounds check so unlicensed users only see their free zone.
/// served as points within a viewport. Public OGL data.
pub async fn get_developments(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<DevelopmentsParams>,
) -> Result<Json<DevelopmentsResponse>, Response> {
let state = shared.load_state();
@ -45,14 +37,6 @@ pub async fn get_developments(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(
&user.0,
(south, west, north, east),
geo.free_zone,
share_bounds,
)?;
let developments = state.developments.clone();
let (sites, total, truncated) =
developments.query_bounds(south, west, north, east, DEVELOPMENTS_LIMIT);

View file

@ -17,7 +17,6 @@ use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::data::{PostcodePoiMetrics, QuantRef};
use crate::features;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
parse_bounds, parse_field_indices_with_poi, parse_filters_with_poi, row_passes_filters,
row_passes_poi_filters, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
@ -36,9 +35,8 @@ const IMAGE_ROW_HEIGHT: f64 = 225.0;
/// Hard cap on the bounding-box area (in degrees²) that may be exported.
/// All of England fits inside ~6° × ~10° ≈ 60 deg². Anything substantially
/// larger is rejected to keep aggregation bounded for non-licensed users
/// who supply share grants outside their expected region, and to avoid
/// minutes-long requests that fan out to millions of rows.
/// larger is rejected to keep aggregation bounded and to avoid minutes-long
/// requests that fan out to millions of rows.
const MAX_EXPORT_BBOX_AREA_DEG2: f64 = 80.0;
#[derive(Deserialize)]
@ -47,7 +45,6 @@ pub struct ExportParams {
filters: Option<String>,
travel: Option<String>,
fields: Option<String>,
share: Option<String>,
/// Comma-separated list of postcodes for list-mode export. When supplied,
/// the bounds / filters / travel parameters are ignored.
postcodes: Option<String>,
@ -230,7 +227,6 @@ fn build_frontend_params(
filters_str: Option<&str>,
travel_params: &[String],
overlay_params: &[String],
share: Option<&str>,
) -> String {
let mut parts = vec![
format!("lat={:.4}", center_lat),
@ -256,9 +252,6 @@ fn build_frontend_params(
parts.push(format!("overlay={}", urlencoding::encode(entry.trim())));
}
}
if let Some(share) = share.filter(|value| !value.is_empty()) {
parts.push(format!("share={}", urlencoding::encode(share)));
}
parts.join("&")
}
@ -446,7 +439,6 @@ pub async fn get_export(
State(shared): State<Arc<SharedState>>,
headers: HeaderMap,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
uri: Uri,
Query(params): Query<ExportParams>,
) -> Result<impl IntoResponse, axum::response::Response> {
@ -501,14 +493,6 @@ pub async fn get_export(
}
}
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(
&user.0,
(south, west, north, east),
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters): (
@ -556,7 +540,6 @@ pub async fn get_export(
collect_overlay_state_params(uri.query())
};
let fields_str = params.fields;
let share_code = params.share;
let public_url = state.public_url.clone();
@ -576,7 +559,6 @@ pub async fn get_export(
filters_str.as_deref(),
&travel_state_params,
&overlay_state_params,
share_code.as_deref(),
);
// Screenshot only makes sense for the spatial / filter mode. In list mode the

View file

@ -3,15 +3,12 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, require_bounds};
use crate::routes::travel_time::parse_optional_travel;
use crate::state::SharedState;
@ -21,7 +18,6 @@ pub struct FilterCountsParams {
bounds: Option<String>,
filters: Option<String>,
travel: Option<String>,
share: Option<String>,
}
#[derive(Serialize)]
@ -32,21 +28,12 @@ pub struct FilterCountsResponse {
pub async fn get_filter_counts(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<FilterCountsParams>,
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(
&user.0,
(south, west, north, east),
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();

View file

@ -7,17 +7,14 @@ static OUT_OF_RANGE_WARN: Once = Once::new();
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::data::PropertyData;
use crate::features::{Feature, FEATURE_GROUPS};
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_with_poi,
row_passes_filters, row_passes_poi_filters, validate_h3_resolution, ParsedEnumFilter,
@ -197,8 +194,6 @@ pub struct HexagonStatsParams {
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
fn default_area_stat_field_set() -> HashSet<String> {
@ -475,8 +470,6 @@ pub(super) fn top_filter_exclusions(
pub async fn get_hexagon_stats(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<HexagonStatsParams>,
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
let state = shared.load_state();
@ -493,11 +486,6 @@ pub async fn get_hexagon_stats(
let resolution = params.resolution;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, h3_bounds, geo.free_zone, share_bounds)?;
let h3_str = params.h3;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();

View file

@ -3,7 +3,6 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use metrics::histogram;
use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};
@ -12,10 +11,8 @@ use serde_json::{Map, Value};
use tracing::info;
use crate::aggregation::{Aggregator, EnumDistConfig, PoiAggregator};
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, needs_parent, parse_enum_dist, parse_field_indices_with_poi,
parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
@ -71,8 +68,6 @@ 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
@ -251,8 +246,6 @@ fn build_feature_maps(
pub async fn get_hexagons(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<HexagonParams>,
) -> Result<Json<HexagonsResponse>, axum::response::Response> {
let state = shared.load_state();
@ -262,14 +255,6 @@ pub async fn get_hexagons(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(
&user.0,
(south, west, north, east),
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(

View file

@ -4,12 +4,9 @@ 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::data::{slugify, PlaceData};
use crate::licensing::{check_license_point, resolve_share_code};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
@ -18,7 +15,6 @@ pub struct JourneyQuery {
postcode: String,
mode: String,
slug: String,
share: Option<String>,
}
#[derive(Serialize)]
@ -56,24 +52,17 @@ fn destination_coordinates(place_data: &PlaceData, slug: &str) -> Option<(f32, f
pub async fn get_journey(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
query: axum::extract::Query<JourneyQuery>,
) -> 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, geo.free_zone, share_bounds)?;
// Validate the postcode exists (404 otherwise); the journey is keyed by
// mode/slug and the postcode string, not the centroid.
if !state.postcode_data.postcode_to_idx.contains_key(&postcode) {
return Err((StatusCode::NOT_FOUND, "Postcode not found").into_response());
}
if !store.has_destination(&query.mode, &query.slug) {
return Err((

View file

@ -3,13 +3,10 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{POSTCODE_SEARCH_OFFSET, PROPERTIES_LIMIT};
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{
parse_field_indices_with_poi, parse_filters_with_poi, row_passes_filters,
row_passes_poi_filters,
@ -34,14 +31,10 @@ pub struct PostcodePropertiesParams {
pub fields: Option<String>,
/// Exact address to rank first when opening properties from address search.
pub focus_address: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
pub async fn get_postcode_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<PostcodePropertiesParams>,
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state();
@ -60,15 +53,6 @@ pub async fn get_postcode_properties(
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
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,
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(

View file

@ -3,13 +3,10 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
@ -31,14 +28,10 @@ pub struct PostcodeStatsParams {
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
pub async fn get_postcode_stats(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
let state = shared.load_state();
@ -58,16 +51,6 @@ pub async fn get_postcode_stats(
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
// License check using postcode centroid
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,
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(

View file

@ -13,7 +13,6 @@ use tracing::info;
use crate::aggregation::{Aggregator, EnumDistConfig, PoiAggregator};
use crate::auth::OptionalUser;
use crate::data::travel_time::TravelData;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
bounds_intersect, parse_enum_dist, parse_field_indices_with_poi, parse_filters_with_poi,
require_bounds, row_passes_filters, row_passes_poi_filters,
@ -47,28 +46,16 @@ 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(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<PostcodeParams>,
) -> Result<Json<PostcodesResponse>, axum::response::Response> {
let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(
&user.0,
(south, west, north, east),
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(

View file

@ -4,15 +4,12 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::PROPERTIES_LIMIT;
use crate::data::{HistoricalPrice, RenovationEvent, TenureEvent};
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_indices_with_poi,
parse_filters_with_poi, row_passes_filters, row_passes_poi_filters, validate_h3_resolution,
@ -35,8 +32,6 @@ pub struct HexagonPropertiesParams {
/// If absent, keeps the legacy behavior and returns all numeric features.
/// If empty, returns only the fixed property card fields.
pub fields: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
#[derive(Serialize)]
@ -273,8 +268,6 @@ pub fn build_property(
pub async fn get_hexagon_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state();
@ -291,11 +284,6 @@ pub async fn get_hexagon_properties(
let resolution = params.resolution;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, h3_bounds, geo.free_zone, share_bounds)?;
let h3_str = params.h3;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();

View file

@ -12,7 +12,6 @@ use url::form_urlencoded;
use crate::auth::OptionalUser;
use crate::language::{language_from_accept_language, query_string_with_language};
use crate::licensing::{is_valid_share_bounds, share_params_and_bounds_from_params, ShareBounds};
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
@ -48,14 +47,6 @@ struct PbRecord {
#[serde(skip_serializing_if = "Option::is_none")]
created_by: Option<String>,
click_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
share_south: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
share_west: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
share_north: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
share_east: Option<f64>,
}
#[derive(Serialize)]
@ -85,7 +76,7 @@ fn json_number_as_u64(value: &serde_json::Value) -> u64 {
.unwrap_or(0)
}
fn sanitized_query_params(params: &str, keep_share: bool) -> Result<String, &'static str> {
fn sanitized_query_params(params: &str) -> Result<String, &'static str> {
let params = params.trim_start_matches('?');
if params.len() > MAX_QUERY_LEN {
return Err("query string is too long");
@ -96,7 +87,8 @@ fn sanitized_query_params(params: &str, keep_share: bool) -> Result<String, &'st
if idx >= MAX_QUERY_PAIRS {
return Err("query string has too many parameters");
}
if key == "share" && !keep_share {
// `share` was a region-grant code; it no longer carries meaning, so drop it.
if key == "share" {
continue;
}
if !is_allowed_param_key(&key) {
@ -145,7 +137,6 @@ fn is_allowed_param_key(key: &str) -> bool {
| "pc"
| "tt"
| "lang"
| "share"
)
}
@ -158,42 +149,7 @@ fn escape_attr(value: &str) -> String {
.replace('>', "&gt;")
}
fn user_can_create_share_grant(user: &OptionalUser) -> bool {
user.0
.as_ref()
.is_some_and(|u| u.is_admin || u.subscription == "licensed")
}
fn share_fields(
bounds: Option<ShareBounds>,
) -> (Option<f64>, Option<f64>, Option<f64>, Option<f64>) {
match bounds {
Some(bounds) => (
Some(bounds.south),
Some(bounds.west),
Some(bounds.north),
Some(bounds.east),
),
None => (None, None, None, None),
}
}
fn record_share_bounds(item: &serde_json::Value) -> Option<ShareBounds> {
let bounds = ShareBounds {
south: item.get("share_south")?.as_f64()?,
west: item.get("share_west")?.as_f64()?,
north: item.get("share_north")?.as_f64()?,
east: item.get("share_east")?.as_f64()?,
};
is_valid_share_bounds(bounds).then_some(bounds)
}
fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> String {
let params = match include_share {
true => params_with_share(params, code),
false => params.to_string(),
};
fn dashboard_redirect_url(params: &str) -> String {
if params.is_empty() {
"/dashboard".to_string()
} else {
@ -201,29 +157,8 @@ fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> Stri
}
}
fn params_with_share(params: &str, code: &str) -> String {
let mut out = form_urlencoded::Serializer::new(String::new());
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
if key == "share" {
continue;
}
out.append_pair(&key, &value);
}
out.append_pair("share", code);
out.finish()
}
fn og_image_url(
public_url: &str,
params: &str,
language: &str,
share_code: Option<&str>,
) -> String {
fn og_image_url(public_url: &str, params: &str, language: &str) -> String {
let params = query_string_with_language(params, language);
let params = match share_code {
Some(code) => params_with_share(&params, code),
None => params,
};
if params.is_empty() {
format!("{}/api/screenshot?og=1", public_url.trim_end_matches('/'))
@ -243,8 +178,7 @@ pub async fn post_shorten(
let state = shared.load_state();
let pb_url = state.pocketbase_url.trim_end_matches('/');
let can_create_share_grant = user_can_create_share_grant(&user);
let mut params = match sanitized_query_params(&req.params, !can_create_share_grant) {
let params = match sanitized_query_params(&req.params) {
Ok(params) => params,
Err(reason) => {
warn!("Rejected short URL params: {reason}");
@ -261,25 +195,11 @@ pub async fn post_shorten(
};
let code = generate_code();
let share_bounds = if can_create_share_grant {
share_params_and_bounds_from_params(&params).map(|(share_params, share_bounds)| {
params = share_params;
share_bounds
})
} else {
None
};
let (share_south, share_west, share_north, share_east) = share_fields(share_bounds);
let record = PbRecord {
code: code.clone(),
params,
created_by: user.0.as_ref().map(|u| u.id.clone()),
click_count: 0,
share_south,
share_west,
share_north,
share_east,
};
let res = state
@ -380,13 +300,7 @@ pub async fn get_share_links(
.map(|item| {
let code = item["code"].as_str().unwrap_or("").to_string();
let params = item["params"].as_str().unwrap_or("").to_string();
let has_share_grant = record_share_bounds(item).is_some();
let og_image_url = og_image_url(
public_url,
&params,
language,
has_share_grant.then_some(code.as_str()),
);
let og_image_url = og_image_url(public_url, &params, language);
ShareLinkListItem {
url: format!("{public_url}/s/{code}"),
code,
@ -459,7 +373,7 @@ pub async fn get_short_url(
let record_id = item["id"].as_str().unwrap_or("").to_string();
let next_click_count =
json_number_as_u64(&item["click_count"]).saturating_add(1);
let params = match sanitized_query_params(params, true) {
let params = match sanitized_query_params(params) {
Ok(params) => params,
Err(reason) => {
warn!("Stored short URL params rejected for {code}: {reason}");
@ -486,14 +400,8 @@ pub async fn get_short_url(
Err(err) => warn!("PocketBase click count update failed: {err}"),
}
}
let has_share_grant = record_share_bounds(item).is_some();
let redirect_url = dashboard_redirect_url(&params, &code, has_share_grant);
let og_image_url = og_image_url(
&state.public_url,
&params,
language,
has_share_grant.then_some(code.as_str()),
);
let redirect_url = dashboard_redirect_url(&params);
let og_image_url = og_image_url(&state.public_url, &params, language);
let og_url = format!("{}/s/{code}", state.public_url.trim_end_matches('/'));
let og_title = "Perfect Postcode | Every neighbourhood in England";
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
@ -555,33 +463,22 @@ mod tests {
#[test]
fn sanitizes_short_url_params_and_drops_share() {
let params = sanitized_query_params(
"lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2&share=oldcode",
false,
)
.unwrap();
let params =
sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2&share=oldcode")
.unwrap();
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2");
}
#[test]
fn rejects_html_in_unsupported_params() {
assert!(sanitized_query_params("lat=51&x=%22%3E%3Cscript%3E", false).is_err());
}
#[test]
fn can_preserve_existing_share_grant() {
let params =
sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&share=oldcode", true).unwrap();
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&share=oldcode");
assert!(sanitized_query_params("lat=51&x=%22%3E%3Cscript%3E").is_err());
}
#[test]
fn preserves_overlay_params_for_share_links() {
let params = sanitized_query_params(
"lat=51.5&lon=-0.1&zoom=12&overlay=noise&overlay=crime-hotspots",
false,
)
.unwrap();
@ -593,8 +490,7 @@ mod tests {
#[test]
fn preserves_basemap_for_share_links() {
let params =
sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&basemap=satellite", false).unwrap();
let params = sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&basemap=satellite").unwrap();
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&basemap=satellite");
}
@ -610,7 +506,7 @@ mod tests {
&tenure=Owner%3A30%3A90\
&crimeType=burglary\
&colorOpacity=60";
let params = sanitized_query_params(query, false).unwrap();
let params = sanitized_query_params(query).unwrap();
assert_eq!(params, query);
}
@ -621,32 +517,19 @@ mod tests {
}
#[test]
fn og_image_url_includes_language_and_share_grant() {
fn og_image_url_includes_language() {
assert_eq!(
og_image_url(
"http://localhost:3001/",
"lat=51.5&lon=-0.1&zoom=12",
"de",
Some("abc123")
),
"http://localhost:3001/api/screenshot?og=1&lat=51.5&lon=-0.1&zoom=12&lang=de&share=abc123"
og_image_url("http://localhost:3001/", "lat=51.5&lon=-0.1&zoom=12", "de"),
"http://localhost:3001/api/screenshot?og=1&lat=51.5&lon=-0.1&zoom=12&lang=de"
);
}
#[test]
fn share_grant_replaces_existing_share_param() {
fn dashboard_redirect_url_wraps_params() {
assert_eq!(
dashboard_redirect_url("lat=51.5&share=oldcode&zoom=12", "newcode", true),
"/dashboard?lat=51.5&zoom=12&share=newcode"
);
assert_eq!(
og_image_url(
"https://perfect-postcodes.co.uk",
"lat=51.5&share=oldcode&zoom=12",
"en",
Some("newcode")
),
"https://perfect-postcodes.co.uk/api/screenshot?og=1&lat=51.5&zoom=12&lang=en&share=newcode"
dashboard_redirect_url("lat=51.5&zoom=12"),
"/dashboard?lat=51.5&zoom=12"
);
assert_eq!(dashboard_redirect_url(""), "/dashboard");
}
}

View file

@ -6,11 +6,10 @@ use rustc_hash::FxHashMap;
use crate::auth::TokenCache;
use crate::bugsink::FrontendConfig as BugsinkFrontendConfig;
use crate::data::{
ActualListingData, AreaCrimeAverages, CrimeByYearData, CrimeRecords,
DevelopmentData, OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData,
PostcodePopulation, PropertyData, TravelTimeStore,
ActualListingData, AreaCrimeAverages, CrimeByYearData, CrimeRecords, DevelopmentData,
OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PostcodePopulation,
PropertyData, TravelTimeStore,
};
use crate::licensing::ShareBoundsCache;
use crate::pocketbase::SuperuserTokenCache;
use crate::routes::FeaturesResponse;
use crate::utils::GridIndex;
@ -65,9 +64,6 @@ 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)
@ -186,7 +182,6 @@ impl AppState {
area_crime_averages: Arc::new(AreaCrimeAverages::empty()),
token_cache: Arc::new(TokenCache::new()),
superuser_token_cache: Arc::new(SuperuserTokenCache::new()),
share_cache: Arc::new(ShareBoundsCache::new()),
screenshot_url: "http://127.0.0.1:1/screenshot".to_string(),
public_url: "https://test.example".to_string(),
is_dev: false,