Improve FAQ & video rendering, tighten homepage and CSS
This commit is contained in:
parent
05a1f316e1
commit
c69bb0d614
48 changed files with 4689 additions and 1077 deletions
1620
server-rs/logs/server.log.2026-05-04
Normal file
1620
server-rs/logs/server.log.2026-05-04
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1227,7 +1227,7 @@ mod tests {
|
|||
|
||||
let mid_value = 50.0;
|
||||
let bin = hist.bin_for_value(mid_value);
|
||||
assert!(bin >= 1 && bin <= 8);
|
||||
assert!((1..=8).contains(&bin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1276,7 +1276,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn count_skips_nan() {
|
||||
let values = vec![1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0];
|
||||
let values = [1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0];
|
||||
let count = values.iter().filter(|v| v.is_finite()).count();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,189 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde_json::json;
|
||||
use parking_lot::RwLock;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::auth::PocketBaseUser;
|
||||
use crate::consts::FREE_ZONE_BOUNDS;
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
const SHARE_CACHE_TTL_SECS: u64 = 300;
|
||||
const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShareBounds {
|
||||
pub south: f64,
|
||||
pub west: f64,
|
||||
pub north: f64,
|
||||
pub east: f64,
|
||||
}
|
||||
|
||||
/// Cache: code → resolved share bounds. We cache `None` too so an invalid
|
||||
/// code doesn't keep hammering PocketBase on every request from a malicious
|
||||
/// or stale client.
|
||||
pub struct ShareBoundsCache {
|
||||
entries: RwLock<FxHashMap<String, (Option<ShareBounds>, Instant)>>,
|
||||
}
|
||||
|
||||
impl ShareBoundsCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: RwLock::new(FxHashMap::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, code: &str) -> Option<Option<ShareBounds>> {
|
||||
let map = self.entries.read();
|
||||
if let Some((bounds, created)) = map.get(code) {
|
||||
if created.elapsed().as_secs() < SHARE_CACHE_TTL_SECS {
|
||||
return Some(*bounds);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn insert(&self, code: String, bounds: Option<ShareBounds>) {
|
||||
let mut map = self.entries.write();
|
||||
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
|
||||
let now = Instant::now();
|
||||
map.retain(|_, (_, created)| {
|
||||
now.duration_since(*created).as_secs() < SHARE_CACHE_TTL_SECS
|
||||
});
|
||||
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
|
||||
let mut ages: Vec<Instant> = map.values().map(|(_, c)| *c).collect();
|
||||
ages.sort();
|
||||
let median = ages[ages.len() / 2];
|
||||
map.retain(|_, (_, created)| *created >= median);
|
||||
}
|
||||
}
|
||||
map.insert(code, (bounds, Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShareBoundsCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a share code to the bbox the share grants access to.
|
||||
/// Looks up the stored params for the code in PocketBase, parses lat/lon/zoom,
|
||||
/// and derives a generous bbox sized to roughly 4× the viewport at that zoom.
|
||||
/// Returns `None` if the code is invalid or unknown.
|
||||
pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
|
||||
if !is_valid_share_code(code) {
|
||||
return None;
|
||||
}
|
||||
if let Some(cached) = state.share_cache.get(code) {
|
||||
return cached;
|
||||
}
|
||||
let resolved = fetch_share_bounds(state, code).await;
|
||||
state.share_cache.insert(code.to_string(), resolved);
|
||||
resolved
|
||||
}
|
||||
|
||||
/// Convenience: resolve `Option<&str>` share code → `Option<ShareBounds>`.
|
||||
/// Skips the lookup entirely (and never touches the cache) when no code is
|
||||
/// supplied or the supplied code is empty.
|
||||
pub async fn resolve_share_code(state: &AppState, code: Option<&str>) -> Option<ShareBounds> {
|
||||
match code {
|
||||
Some(c) if !c.is_empty() => lookup_share_bounds(state, c).await,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_share_code(code: &str) -> bool {
|
||||
!code.is_empty() && code.len() <= 20 && code.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
|
||||
let token = match get_superuser_token(state).await {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("share bounds lookup: superuser auth failed: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("code=\"{code}\"");
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let json: Value = resp.json().await.ok()?;
|
||||
let params = json["items"]
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("params")?
|
||||
.as_str()?;
|
||||
parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom))
|
||||
}
|
||||
|
||||
/// Pull `lat`, `lon`, `zoom` out of an already-encoded query string like
|
||||
/// `lat=51.5&lon=-0.1&zoom=12&filter=...`. Returns `None` if any of the three
|
||||
/// is missing or unparseable — those are the only fields we need for sizing.
|
||||
fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> {
|
||||
let mut lat: Option<f64> = None;
|
||||
let mut lon: Option<f64> = None;
|
||||
let mut zoom: Option<f64> = None;
|
||||
for pair in params.split('&') {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
let key = it.next()?;
|
||||
let val = it.next().unwrap_or("");
|
||||
match key {
|
||||
"lat" => lat = val.parse().ok(),
|
||||
"lon" => lon = val.parse().ok(),
|
||||
"zoom" => zoom = val.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((lat?, lon?, zoom?))
|
||||
}
|
||||
|
||||
/// Derive the share bbox from the share's center lat/lon and zoom.
|
||||
///
|
||||
/// A viewport W pixels wide at zoom z covers `W * 360 / (256 * 2^z)` degrees
|
||||
/// of longitude. For a typical 1280px-wide desktop viewport that's roughly
|
||||
/// `1800 / 2^z` degrees — we use that as the half-width, so the bbox is
|
||||
/// ~2 viewports per side (~4 viewports total area). Lat is scaled by 0.6
|
||||
/// to roughly match the latitude compression at UK latitudes.
|
||||
fn bounds_from_view(lat: f64, lon: f64, zoom: f64) -> ShareBounds {
|
||||
let zoom = zoom.clamp(0.0, 20.0);
|
||||
let half_lon = (1800.0 / 2.0_f64.powf(zoom)).min(180.0);
|
||||
let half_lat = (half_lon * 0.6).min(85.0);
|
||||
ShareBounds {
|
||||
south: lat - half_lat,
|
||||
north: lat + half_lat,
|
||||
west: lon - half_lon,
|
||||
east: lon + half_lon,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the user is allowed to query data at the given bounds.
|
||||
/// Licensed users and admins bypass the check entirely.
|
||||
/// Free/anonymous users get 403 if bounds exceed the free zone.
|
||||
/// Free/anonymous users get 403 unless the bounds fall inside the free zone
|
||||
/// or inside the bbox granted by a valid share code.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn check_license_bounds(
|
||||
user: &Option<PocketBaseUser>,
|
||||
bounds: (f64, f64, f64, f64),
|
||||
share_bounds: Option<ShareBounds>,
|
||||
) -> Result<(), axum::response::Response> {
|
||||
if let Some(u) = user {
|
||||
if u.is_admin || u.subscription == "licensed" {
|
||||
|
|
@ -26,6 +198,12 @@ pub fn check_license_bounds(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(sb) = share_bounds {
|
||||
if south >= sb.south && west >= sb.west && north <= sb.north && east <= sb.east {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"error": "license_required",
|
||||
"message": "A license is required to view data outside the demo area",
|
||||
|
|
@ -46,6 +224,7 @@ pub fn check_license_point(
|
|||
user: &Option<PocketBaseUser>,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
share_bounds: Option<ShareBounds>,
|
||||
) -> Result<(), axum::response::Response> {
|
||||
check_license_bounds(user, (lat, lon, lat, lon))
|
||||
check_license_bounds(user, (lat, lon, lat, lon), share_bounds)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -387,6 +387,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let token_cache = Arc::new(auth::TokenCache::new());
|
||||
let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
|
||||
let share_cache = Arc::new(licensing::ShareBoundsCache::new());
|
||||
|
||||
let app_state = AppState {
|
||||
data: property_data,
|
||||
|
|
@ -416,6 +417,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
travel_time_store,
|
||||
token_cache,
|
||||
superuser_token_cache,
|
||||
share_cache,
|
||||
ai_filters_system_prompt,
|
||||
google_maps_api_key: cli.google_maps_api_key,
|
||||
stripe_secret_key: cli.stripe_secret_key,
|
||||
|
|
@ -498,7 +500,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
post(routes::post_ai_filters).layer(ConcurrencyLimitLayer::new(5)),
|
||||
)
|
||||
.route("/api/streetview", get(routes::get_streetview))
|
||||
.route("/api/newsletter", patch(routes::patch_newsletter))
|
||||
.route(
|
||||
"/api/newsletter",
|
||||
patch(routes::patch_newsletter).layer(ConcurrencyLimitLayer::new(10)),
|
||||
)
|
||||
.route("/api/pricing", get(routes::get_pricing))
|
||||
.route(
|
||||
"/api/checkout",
|
||||
|
|
@ -512,7 +517,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
.route("/api/invite/{code}", get(routes::get_invite))
|
||||
.route("/api/redeem-invite", post(routes::post_redeem_invite))
|
||||
.route("/s/{code}", get(routes::get_short_url))
|
||||
.route("/api/telemetry", post(routes::post_telemetry))
|
||||
.route(
|
||||
"/api/telemetry",
|
||||
post(routes::post_telemetry).layer(ConcurrencyLimitLayer::new(20)),
|
||||
)
|
||||
.route(
|
||||
"/pb/{*rest}",
|
||||
any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,21 @@ use crate::state::AppState;
|
|||
const OG_PLACEHOLDER: &str =
|
||||
r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
|
||||
|
||||
/// Escape a string for safe inclusion inside a double-quoted HTML attribute value.
|
||||
fn escape_attr(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub async fn og_middleware(request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path().to_string();
|
||||
// Capture the query string before passing the request through
|
||||
|
|
@ -46,32 +61,34 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
None => return response,
|
||||
};
|
||||
|
||||
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot)
|
||||
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot).
|
||||
// All URL components are HTML-escaped before interpolation into attributes
|
||||
// because path/query are attacker-controlled.
|
||||
let is_invite = path.starts_with("/invite/");
|
||||
let path_e = escape_attr(&path);
|
||||
let query_e = escape_attr(&query_string);
|
||||
let public_url_e = escape_attr(&state.public_url);
|
||||
|
||||
let og_image_url = if is_invite {
|
||||
// Include path= so the screenshot service navigates to /invite/CODE
|
||||
if query_string.is_empty() {
|
||||
format!("{}/api/screenshot?og=1&path={}", state.public_url, path)
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}")
|
||||
} else {
|
||||
format!(
|
||||
"{}/api/screenshot?og=1&path={}&{}",
|
||||
state.public_url, path, query_string
|
||||
)
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}&{query_e}")
|
||||
}
|
||||
} else if query_string.is_empty() {
|
||||
format!("{}/api/screenshot?og=1", state.public_url)
|
||||
format!("{public_url_e}/api/screenshot?og=1")
|
||||
} else {
|
||||
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
|
||||
format!("{public_url_e}/api/screenshot?og=1&{query_e}")
|
||||
};
|
||||
|
||||
let og_url = if query_string.is_empty() {
|
||||
format!("{}{}", state.public_url, path)
|
||||
format!("{public_url_e}{path_e}")
|
||||
} else {
|
||||
format!("{}{}?{}", state.public_url, path, query_string)
|
||||
format!("{public_url_e}{path_e}?{query_e}")
|
||||
};
|
||||
|
||||
let og_logo = format!("{}/favicon.svg", state.public_url);
|
||||
let og_logo = format!("{public_url_e}/favicon.svg");
|
||||
|
||||
let (og_title, og_description) = if is_invite {
|
||||
(
|
||||
|
|
|
|||
|
|
@ -86,6 +86,13 @@ pub fn parse_filters(
|
|||
.trim()
|
||||
.parse::<f32>()
|
||||
.map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?;
|
||||
// Reject inverted ranges: keeps the selectivity sort key (max - min) non-negative
|
||||
// and surfaces user error instead of silently returning zero rows.
|
||||
if min.is_finite() && max.is_finite() && min > max {
|
||||
return Err(format!(
|
||||
"Numeric filter '{name}' has inverted range: min ({min}) > max ({max})"
|
||||
));
|
||||
}
|
||||
numeric.push(ParsedFilter {
|
||||
feat_idx,
|
||||
min_u16: quant.encode_min(feat_idx, min),
|
||||
|
|
@ -94,8 +101,10 @@ pub fn parse_filters(
|
|||
}
|
||||
}
|
||||
|
||||
// Sort by selectivity: more selective filters first for early rejection
|
||||
numeric.sort_unstable_by_key(|f| f.max_u16.wrapping_sub(f.min_u16));
|
||||
// Sort by selectivity: more selective filters first for early rejection.
|
||||
// Use saturating_sub so a hypothetical inverted range (min > max) yields 0
|
||||
// rather than wrapping to a huge u16 and corrupting the sort.
|
||||
numeric.sort_unstable_by_key(|f| f.max_u16.saturating_sub(f.min_u16));
|
||||
enums.sort_unstable_by_key(|f| f.allowed.len());
|
||||
|
||||
Ok((numeric, enums))
|
||||
|
|
@ -418,6 +427,67 @@ mod tests {
|
|||
assert_eq!(enums.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_rejects_inverted_range() {
|
||||
// Inverted bounds (min > max) would previously produce a wrapping
|
||||
// selectivity key in the sort and silently match zero rows. Now we
|
||||
// reject them at parse time with a clear error.
|
||||
let tq = test_quant(3, 2);
|
||||
let result = parse_filters(
|
||||
Some("price:500:100"),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
&tq.as_ref(),
|
||||
);
|
||||
assert!(result.is_err(), "expected inverted range to be rejected");
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.contains("inverted range"), "got: {err}");
|
||||
assert!(err.contains("price"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_accepts_equal_min_max() {
|
||||
// min == max is a valid (degenerate) range and must still be accepted.
|
||||
let tq = test_quant(3, 2);
|
||||
let (numeric, _) = parse_filters(
|
||||
Some("price:200:200"),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(numeric.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selectivity_sort_handles_saturating_sub() {
|
||||
// Even if a ParsedFilter is constructed directly with min_u16 > max_u16
|
||||
// (bypassing parse_filters validation), the sort must not wrap to a
|
||||
// huge u16 — saturating_sub clamps to 0.
|
||||
let mut filters = [
|
||||
ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min_u16: 1000,
|
||||
max_u16: 2000, // range = 1000
|
||||
},
|
||||
ParsedFilter {
|
||||
feat_idx: 1,
|
||||
min_u16: 5000,
|
||||
max_u16: 100, // inverted; saturating gives 0 (most "selective")
|
||||
},
|
||||
ParsedFilter {
|
||||
feat_idx: 2,
|
||||
min_u16: 0,
|
||||
max_u16: 500, // range = 500
|
||||
},
|
||||
];
|
||||
filters.sort_unstable_by_key(|f| f.max_u16.saturating_sub(f.min_u16));
|
||||
// Sorted ascending by saturating range: inverted (0), then 500, then 1000.
|
||||
assert_eq!(filters[0].feat_idx, 1);
|
||||
assert_eq!(filters[1].feat_idx, 2);
|
||||
assert_eq!(filters[2].feat_idx, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_numeric_format_errors() {
|
||||
let tq = test_quant(4, 2);
|
||||
|
|
@ -627,8 +697,7 @@ mod tests {
|
|||
},
|
||||
];
|
||||
|
||||
let (total, impacts) =
|
||||
count_filter_impacts(&filters, &[], &feature_data, 2, (0..4u32).into_iter());
|
||||
let (total, impacts) = count_filter_impacts(&filters, &[], &feature_data, 2, 0..4u32);
|
||||
|
||||
assert_eq!(total, 1); // only row 0 passes
|
||||
assert_eq!(impacts[0], 1); // row 1 fails price only
|
||||
|
|
@ -661,13 +730,8 @@ mod tests {
|
|||
allowed: [0u16, 1].into_iter().collect(),
|
||||
}];
|
||||
|
||||
let (total, impacts) = count_filter_impacts(
|
||||
&num_filters,
|
||||
&enum_filters,
|
||||
&feature_data,
|
||||
2,
|
||||
(0..3u32).into_iter(),
|
||||
);
|
||||
let (total, impacts) =
|
||||
count_filter_impacts(&num_filters, &enum_filters, &feature_data, 2, 0..3u32);
|
||||
|
||||
assert_eq!(total, 1); // row 0
|
||||
assert_eq!(impacts[0], 1); // row 2 fails numeric only → impacts[0]
|
||||
|
|
@ -678,8 +742,7 @@ mod tests {
|
|||
fn filter_impacts_no_filters() {
|
||||
let tq = test_quant(1, 1);
|
||||
let feature_data = vec![tq.encode(0, 100.0)];
|
||||
let (total, impacts) =
|
||||
count_filter_impacts(&[], &[], &feature_data, 1, (0..1u32).into_iter());
|
||||
let (total, impacts) = count_filter_impacts(&[], &[], &feature_data, 1, 0..1u32);
|
||||
assert_eq!(total, 1);
|
||||
assert!(impacts.is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ pub async fn get_export(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
check_license_bounds(&user.0, (south, west, north, east), None)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters,
|
||||
row_passes_filters, validate_h3_resolution,
|
||||
|
|
@ -76,6 +76,8 @@ pub struct HexagonStatsParams {
|
|||
/// shortest travel time for this mode+slug (so it has journey data).
|
||||
pub journey_mode: Option<String>,
|
||||
pub journey_slug: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_hexagon_stats(
|
||||
|
|
@ -99,7 +101,8 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, h3_bounds, share_bounds)?;
|
||||
|
||||
let h3_str = params.h3;
|
||||
let quant = state.data.quant_ref();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use crate::aggregation::{Aggregator, EnumDistConfig};
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
cell_for_row_cached, needs_parent, parse_enum_dist, parse_field_indices, parse_filters,
|
||||
require_bounds, row_passes_filters, validate_h3_resolution,
|
||||
|
|
@ -68,6 +68,8 @@ pub struct HexagonParams {
|
|||
/// Feature name for enum distribution counting (pie chart visualization).
|
||||
/// When set, each cell includes `dist_{name}: [count_val0, count_val1, ...]`.
|
||||
enum_dist: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
/// Build feature maps from aggregated cell data, filtering to only cells whose
|
||||
|
|
@ -203,7 +205,8 @@ pub async fn get_hexagons(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
|
||||
use crate::licensing::check_license_point;
|
||||
use crate::licensing::{check_license_point, resolve_share_code};
|
||||
use crate::parsing::{parse_filters, row_passes_filters};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
|
@ -22,6 +22,8 @@ pub struct PostcodePropertiesParams {
|
|||
pub filters: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_postcode_properties(
|
||||
|
|
@ -45,7 +47,13 @@ pub async fn get_postcode_properties(
|
|||
};
|
||||
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_point(
|
||||
&user.0,
|
||||
centroid_lat as f64,
|
||||
centroid_lon as f64,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::POSTCODE_SEARCH_OFFSET;
|
||||
use crate::licensing::check_license_point;
|
||||
use crate::licensing::{check_license_point, resolve_share_code};
|
||||
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
|
@ -24,6 +24,8 @@ pub struct PostcodeStatsParams {
|
|||
/// Comma-separated feature names to include in stats response.
|
||||
/// Only listed features are computed; if absent or empty, no features are returned.
|
||||
pub fields: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_postcode_stats(
|
||||
|
|
@ -49,7 +51,13 @@ pub async fn get_postcode_stats(
|
|||
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
// License check using postcode centroid
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_point(
|
||||
&user.0,
|
||||
centroid_lat as f64,
|
||||
centroid_lon as f64,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::aggregation::{Aggregator, EnumDistConfig};
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
bounds_intersect, parse_enum_dist, parse_field_indices, parse_filters, require_bounds,
|
||||
row_passes_filters,
|
||||
|
|
@ -47,6 +47,8 @@ pub struct PostcodeParams {
|
|||
travel: Option<String>,
|
||||
/// Feature name for enum distribution counting (pie chart visualization).
|
||||
enum_dist: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_postcodes(
|
||||
|
|
@ -58,7 +60,8 @@ pub async fn get_postcodes(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ use tracing::warn;
|
|||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::{AppState, SharedState};
|
||||
|
||||
/// Pricing tiers: (cumulative user cap, price in pence).
|
||||
const TIERS: &[(u64, u64)] = &[(5, 99), (15, 999), (30, 2999), (50, 4999)];
|
||||
/// Pricing tiers: (cumulative public user cap, price in pence).
|
||||
const TIERS: &[(u64, u64)] = &[(50, 99), (150, 999), (250, 2999), (350, 4999)];
|
||||
const FINAL_PRICE_PENCE: u64 = 9999;
|
||||
const PUBLIC_LAUNCH_USER_OFFSET: u64 = 120;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Tier {
|
||||
|
|
@ -28,16 +29,24 @@ pub struct PricingResponse {
|
|||
tiers: Vec<Tier>,
|
||||
}
|
||||
|
||||
/// Determine the price (in pence) for the next user given `count` existing licensed users.
|
||||
pub fn price_for_count(count: u64) -> u64 {
|
||||
fn public_licensed_count(actual_count: u64) -> u64 {
|
||||
actual_count.saturating_add(PUBLIC_LAUNCH_USER_OFFSET)
|
||||
}
|
||||
|
||||
fn price_for_public_count(public_count: u64) -> u64 {
|
||||
for &(cap, price) in TIERS {
|
||||
if count < cap {
|
||||
if public_count < cap {
|
||||
return price;
|
||||
}
|
||||
}
|
||||
FINAL_PRICE_PENCE
|
||||
}
|
||||
|
||||
/// Determine the price (in pence) for the next user given the real licensed user count.
|
||||
pub fn price_for_count(count: u64) -> u64 {
|
||||
price_for_public_count(public_licensed_count(count))
|
||||
}
|
||||
|
||||
/// Count users with subscription="licensed" in PocketBase.
|
||||
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
|
|
@ -75,7 +84,8 @@ pub async fn get_pricing(State(shared): State<Arc<SharedState>>) -> Response {
|
|||
}
|
||||
};
|
||||
|
||||
let current_price = price_for_count(count);
|
||||
let public_count = public_licensed_count(count);
|
||||
let current_price = price_for_public_count(public_count);
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
let mut prev_cap = 0u64;
|
||||
|
|
@ -94,9 +104,44 @@ pub async fn get_pricing(State(shared): State<Arc<SharedState>>) -> Response {
|
|||
});
|
||||
|
||||
Json(PricingResponse {
|
||||
licensed_count: count,
|
||||
licensed_count: public_count,
|
||||
current_price_pence: current_price,
|
||||
tiers,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_count_starts_at_launch_offset() {
|
||||
assert_eq!(public_licensed_count(0), 120);
|
||||
assert_eq!(public_licensed_count(7), 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_for_count_uses_public_tier_position() {
|
||||
assert_eq!(price_for_count(0), 999);
|
||||
assert_eq!(price_for_count(29), 999);
|
||||
assert_eq!(price_for_count(30), 2999);
|
||||
assert_eq!(price_for_count(129), 2999);
|
||||
assert_eq!(price_for_count(130), 4999);
|
||||
assert_eq!(price_for_count(229), 4999);
|
||||
assert_eq!(price_for_count(230), 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_for_public_count_uses_tier_caps() {
|
||||
assert_eq!(price_for_public_count(0), 99);
|
||||
assert_eq!(price_for_public_count(49), 99);
|
||||
assert_eq!(price_for_public_count(50), 999);
|
||||
assert_eq!(price_for_public_count(149), 999);
|
||||
assert_eq!(price_for_public_count(150), 2999);
|
||||
assert_eq!(price_for_public_count(249), 2999);
|
||||
assert_eq!(price_for_public_count(250), 4999);
|
||||
assert_eq!(price_for_public_count(349), 4999);
|
||||
assert_eq!(price_for_public_count(350), 9999);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use tracing::{info, warn};
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
|
||||
use crate::data::RenovationEvent;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
|
||||
validate_h3_resolution,
|
||||
|
|
@ -26,6 +26,8 @@ pub struct HexagonPropertiesParams {
|
|||
pub filters: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -187,7 +189,8 @@ pub async fn get_hexagon_properties(
|
|||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, h3_bounds, share_bounds)?;
|
||||
|
||||
let h3_str = params.h3;
|
||||
let quant = state.data.quant_ref();
|
||||
|
|
|
|||
|
|
@ -139,7 +139,11 @@ pub async fn get_short_url(
|
|||
|
||||
match params {
|
||||
Some(params) => {
|
||||
let redirect_url = format!("/dashboard?{params}");
|
||||
let redirect_url = if params.is_empty() {
|
||||
format!("/dashboard?share={code}")
|
||||
} else {
|
||||
format!("/dashboard?{params}&share={code}")
|
||||
};
|
||||
let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url);
|
||||
let og_url = format!("{}/s/{code}", state.public_url);
|
||||
let og_title = "Perfect Postcode | Every neighbourhood in England";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
use std::sync::Arc;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use hmac::{Hmac, Mac};
|
||||
use parking_lot::Mutex;
|
||||
use rustc_hash::FxHashSet;
|
||||
use sha2::Sha256;
|
||||
use tracing::{info, warn};
|
||||
|
||||
|
|
@ -13,6 +16,46 @@ use crate::state::SharedState;
|
|||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Process-local LRU of recently processed Stripe event IDs.
|
||||
/// Stripe retries deliver the same event ID; we drop duplicates so we don't
|
||||
/// re-run side effects (subscription writes, token cache invalidation, logs).
|
||||
/// Capacity is intentionally generous: at typical webhook volumes this covers
|
||||
/// far more than Stripe's retry window.
|
||||
struct EventDedup {
|
||||
seen: FxHashSet<String>,
|
||||
queue: VecDeque<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl EventDedup {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
seen: FxHashSet::default(),
|
||||
queue: VecDeque::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this event ID is new (and records it),
|
||||
/// `false` if it was already seen recently.
|
||||
fn check_and_insert(&mut self, id: &str) -> bool {
|
||||
if self.seen.contains(id) {
|
||||
return false;
|
||||
}
|
||||
self.seen.insert(id.to_string());
|
||||
self.queue.push_back(id.to_string());
|
||||
if self.queue.len() > self.capacity {
|
||||
if let Some(old) = self.queue.pop_front() {
|
||||
self.seen.remove(&old);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
static EVENT_DEDUP: LazyLock<Mutex<EventDedup>> =
|
||||
LazyLock::new(|| Mutex::new(EventDedup::new(1024)));
|
||||
|
||||
/// Verify Stripe webhook signature (v1 scheme).
|
||||
fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
|
||||
// Parse timestamp and signature from header: "t=TIMESTAMP,v1=SIGNATURE"
|
||||
|
|
@ -95,7 +138,16 @@ pub async fn post_stripe_webhook(
|
|||
};
|
||||
|
||||
let event_type = event["type"].as_str().unwrap_or("");
|
||||
info!(event_type, "Received Stripe webhook");
|
||||
let event_id = event["id"].as_str().unwrap_or("");
|
||||
|
||||
// Idempotency: drop replays/retries of an already-processed event.
|
||||
// We always answer 200 so Stripe stops retrying.
|
||||
if !event_id.is_empty() && !EVENT_DEDUP.lock().check_and_insert(event_id) {
|
||||
info!(event_id, event_type, "Dropping duplicate Stripe webhook");
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
info!(event_id, event_type, "Received Stripe webhook");
|
||||
|
||||
if event_type == "checkout.session.completed" {
|
||||
let user_id = event["data"]["object"]["client_reference_id"]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::auth::TokenCache;
|
|||
use crate::data::{
|
||||
OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
|
||||
};
|
||||
use crate::licensing::ShareBoundsCache;
|
||||
use crate::pocketbase::SuperuserTokenCache;
|
||||
use crate::routes::FeaturesResponse;
|
||||
use crate::utils::GridIndex;
|
||||
|
|
@ -45,6 +46,9 @@ pub struct AppState {
|
|||
pub token_cache: Arc<TokenCache>,
|
||||
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
|
||||
pub superuser_token_cache: Arc<SuperuserTokenCache>,
|
||||
/// Cached share-link bbox lookups (5min TTL); used to grant unlicensed
|
||||
/// users access to the area their share link references.
|
||||
pub share_cache: Arc<ShareBoundsCache>,
|
||||
|
||||
// --- Config (cheap to clone) ---
|
||||
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue