lgtm
This commit is contained in:
parent
5e73287eaf
commit
e2b85fe819
73 changed files with 1180 additions and 2028 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const DASHBOARD_POI_GROUPS: &[(&str, &[&str])] = &[
|
|||
&[
|
||||
"Rail station",
|
||||
"Tube station",
|
||||
"DLR station",
|
||||
"Tram & Metro stop",
|
||||
"Bus station",
|
||||
"Bus stop",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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\
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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((
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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('>', ">")
|
||||
}
|
||||
|
||||
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(¶ms, 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(¶ms).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,
|
||||
¶ms,
|
||||
language,
|
||||
has_share_grant.then_some(code.as_str()),
|
||||
);
|
||||
let og_image_url = og_image_url(public_url, ¶ms, 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(¶ms, &code, has_share_grant);
|
||||
let og_image_url = og_image_url(
|
||||
&state.public_url,
|
||||
¶ms,
|
||||
language,
|
||||
has_share_grant.then_some(code.as_str()),
|
||||
);
|
||||
let redirect_url = dashboard_redirect_url(¶ms);
|
||||
let og_image_url = og_image_url(&state.public_url, ¶ms, 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue