lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
80
server-rs/Cargo.lock
generated
80
server-rs/Cargo.lock
generated
|
|
@ -302,6 +302,15 @@ version = "2.10.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "7.0.0"
|
||||
|
|
@ -564,6 +573,15 @@ version = "0.8.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
|
|
@ -645,6 +663,16 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debug_unsafe"
|
||||
version = "0.1.3"
|
||||
|
|
@ -662,6 +690,17 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
|
@ -942,6 +981,16 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
|
|
@ -1062,6 +1111,15 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "705f81e042b11734af35c701c7f6b65f8a968a430621fa2c95e72e27f9f8be5c"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
|
|
@ -2368,6 +2426,8 @@ dependencies = [
|
|||
"axum",
|
||||
"clap",
|
||||
"h3o",
|
||||
"hex",
|
||||
"hmac",
|
||||
"lasso",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
|
|
@ -2381,7 +2441,9 @@ dependencies = [
|
|||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
@ -2925,6 +2987,17 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
|
@ -3347,6 +3420,7 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -3461,6 +3535,12 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ urlencoding = "2"
|
|||
rust_xlsxwriter = "0.79"
|
||||
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
|
||||
rand = "0.9"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tower = { version = "0.5", features = ["limit"] }
|
||||
|
||||
[lints.clippy]
|
||||
min_ident_chars = "warn"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ pub struct PocketBaseUser {
|
|||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub verified: bool,
|
||||
#[serde(default)]
|
||||
pub is_admin: bool,
|
||||
#[serde(default)]
|
||||
pub subscription: String,
|
||||
#[serde(default)]
|
||||
pub newsletter: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -58,6 +64,12 @@ impl TokenCache {
|
|||
}
|
||||
map.insert(token, (user, Instant::now()));
|
||||
}
|
||||
|
||||
/// Remove all cached tokens for a given user ID so the next request re-validates.
|
||||
pub fn invalidate_by_user_id(&self, user_id: &str) {
|
||||
let mut map = self.entries.write();
|
||||
map.retain(|_, (user, _)| user.id != user_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
|||
|
|
@ -20,3 +20,7 @@ pub const AREA_SUMMARY_TEMPERATURE: f32 = 0.3;
|
|||
|
||||
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
|
||||
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
|
||||
|
||||
/// Inner London free zone bounds (south, west, north, east) — roughly zones 1–2.
|
||||
/// Users without a license can only query data within these bounds.
|
||||
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.48, -0.18, 51.54, -0.02);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ mod places;
|
|||
mod poi;
|
||||
mod postcodes;
|
||||
mod property;
|
||||
pub mod travel_time;
|
||||
|
||||
pub use places::PlaceData;
|
||||
pub use poi::{POICategoryGroup, POIData};
|
||||
pub use postcodes::PostcodeData;
|
||||
pub use property::{precompute_h3, FeatureStats, Histogram, PropertyData, RenovationEvent};
|
||||
pub use travel_time::{slugify, TravelTimeStore};
|
||||
|
|
|
|||
168
server-rs/src/data/places.rs
Normal file
168
server-rs/src/data/places.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use polars::frame::DataFrame;
|
||||
use polars::lazy::frame::LazyFrame;
|
||||
use polars::prelude::*;
|
||||
use tracing::info;
|
||||
|
||||
use crate::utils::InternedColumn;
|
||||
|
||||
pub struct PlaceData {
|
||||
pub name: Vec<String>,
|
||||
pub name_lower: Vec<String>,
|
||||
pub place_type: InternedColumn,
|
||||
pub type_rank: Vec<u8>,
|
||||
pub population: Vec<u32>,
|
||||
pub lat: Vec<f32>,
|
||||
pub lon: Vec<f32>,
|
||||
pub city: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
fn type_rank(place_type: &str) -> u8 {
|
||||
match place_type {
|
||||
"city" => 0,
|
||||
"borough" => 1,
|
||||
"town" => 2,
|
||||
"suburb" => 3,
|
||||
"quarter" => 4,
|
||||
"neighbourhood" => 5,
|
||||
"village" => 6,
|
||||
"station" => 7,
|
||||
"island" => 8,
|
||||
"hamlet" => 9,
|
||||
"locality" => 10,
|
||||
"isolated_dwelling" => 11,
|
||||
_ => 12,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
|
||||
let column = df
|
||||
.column(name)
|
||||
.with_context(|| format!("Missing column '{name}' in places data"))?;
|
||||
let string_column = column
|
||||
.str()
|
||||
.with_context(|| format!("Column '{name}' is not a string column"))?;
|
||||
Ok(string_column
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or("").to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn extract_f32_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<f32>> {
|
||||
let column = df
|
||||
.column(name)
|
||||
.with_context(|| format!("Missing column '{name}' in places data"))?;
|
||||
let cast = column
|
||||
.cast(&DataType::Float32)
|
||||
.with_context(|| format!("Failed to cast column '{name}' to Float32"))?;
|
||||
let float_column = cast
|
||||
.f32()
|
||||
.with_context(|| format!("Column '{name}' is not a float32 column"))?;
|
||||
Ok(float_column
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or(0.0))
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl PlaceData {
|
||||
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
|
||||
info!("Loading place data from {:?}...", parquet_path);
|
||||
|
||||
let df = LazyFrame::scan_parquet(parquet_path, Default::default())
|
||||
.context("Failed to scan places parquet")?
|
||||
.collect()
|
||||
.context("Failed to read places parquet")?;
|
||||
|
||||
let row_count = df.height();
|
||||
info!("Loaded {} places", row_count);
|
||||
|
||||
let name = extract_str_col(&df, "name")?;
|
||||
let place_type_raw = extract_str_col(&df, "place_type")?;
|
||||
let lat = extract_f32_col(&df, "lat")?;
|
||||
let lon = extract_f32_col(&df, "lon")?;
|
||||
let population: Vec<u32> = if df.column("population").is_ok() {
|
||||
let pop_f32 = extract_f32_col(&df, "population")?;
|
||||
pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
|
||||
} else {
|
||||
vec![0; row_count]
|
||||
};
|
||||
|
||||
let name_lower: Vec<String> = name.iter().map(|nm| nm.to_lowercase()).collect();
|
||||
let type_rank_vec: Vec<u8> = place_type_raw.iter().map(|pt| type_rank(pt)).collect();
|
||||
let place_type = InternedColumn::build(&place_type_raw);
|
||||
|
||||
// Precompute nearest city for each non-city place
|
||||
let city_indices: Vec<usize> = type_rank_vec
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
|
||||
.collect();
|
||||
|
||||
let city: Vec<Option<String>> = (0..row_count)
|
||||
.map(|idx| {
|
||||
if type_rank_vec[idx] == 0 {
|
||||
return None; // Cities don't need a city label
|
||||
}
|
||||
let plat = lat[idx];
|
||||
let plon = lon[idx];
|
||||
let cos_lat = (plat.to_radians()).cos();
|
||||
|
||||
let mut best_dist_sq = f32::MAX;
|
||||
let mut best_city: Option<&str> = None;
|
||||
for &ci in &city_indices {
|
||||
let dlat = lat[ci] - plat;
|
||||
let dlon = (lon[ci] - plon) * cos_lat;
|
||||
let dist_sq = dlat * dlat + dlon * dlon;
|
||||
if dist_sq < best_dist_sq {
|
||||
best_dist_sq = dist_sq;
|
||||
best_city = Some(&name[ci]);
|
||||
}
|
||||
}
|
||||
|
||||
// ~100km threshold: 1° ≈ 111km, so 0.9° ≈ 100km → 0.81 squared
|
||||
if best_dist_sq < 0.81 {
|
||||
best_city.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let with_pop = population.iter().filter(|&&pop| pop > 0).count();
|
||||
let with_city = city.iter().filter(|c| c.is_some()).count();
|
||||
info!(
|
||||
places = row_count,
|
||||
types = place_type.values.len(),
|
||||
with_population = with_pop,
|
||||
with_city = with_city,
|
||||
"Place data loaded"
|
||||
);
|
||||
|
||||
Ok(PlaceData {
|
||||
name,
|
||||
name_lower,
|
||||
place_type,
|
||||
type_rank: type_rank_vec,
|
||||
population,
|
||||
lat,
|
||||
lon,
|
||||
city,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn type_rank_ordering() {
|
||||
assert!(type_rank("city") < type_rank("town"));
|
||||
assert!(type_rank("town") < type_rank("suburb"));
|
||||
assert!(type_rank("suburb") < type_rank("village"));
|
||||
assert!(type_rank("village") < type_rank("hamlet"));
|
||||
assert!(type_rank("hamlet") < type_rank("isolated_dwelling"));
|
||||
}
|
||||
}
|
||||
149
server-rs/src/data/poi.rs
Normal file
149
server-rs/src/data/poi.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use polars::frame::DataFrame;
|
||||
use polars::lazy::frame::LazyFrame;
|
||||
use polars::prelude::*;
|
||||
use serde::Serialize;
|
||||
use tracing::info;
|
||||
|
||||
use crate::features::POI_GROUP_ORDER;
|
||||
use crate::utils::{generate_priorities, InternedColumn};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct POICategoryGroup {
|
||||
pub name: String,
|
||||
pub categories: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct POIData {
|
||||
pub id: Vec<String>,
|
||||
pub group: InternedColumn,
|
||||
pub category: InternedColumn,
|
||||
pub name: Vec<String>,
|
||||
pub lat: Vec<f32>,
|
||||
pub lng: Vec<f32>,
|
||||
pub emoji: InternedColumn,
|
||||
/// Deterministic pseudo-random priority per row, used to select a spatially
|
||||
/// uniform subset when the POI count exceeds the per-request limit.
|
||||
/// Computed once at load time so the same POIs are always chosen for a given viewport.
|
||||
pub priority: Vec<u32>,
|
||||
}
|
||||
|
||||
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
|
||||
let column = df
|
||||
.column(name)
|
||||
.with_context(|| format!("Missing column '{name}' in POI data"))?;
|
||||
let string_column = column
|
||||
.str()
|
||||
.with_context(|| format!("Column '{name}' is not a string column"))?;
|
||||
Ok(string_column
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or("").to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn extract_f32_col(df: &DataFrame, name: &str, default: f32) -> anyhow::Result<Vec<f32>> {
|
||||
let column = df
|
||||
.column(name)
|
||||
.with_context(|| format!("Missing column '{name}' in POI data"))?;
|
||||
let cast = column
|
||||
.cast(&DataType::Float32)
|
||||
.with_context(|| format!("Failed to cast column '{name}' to Float32"))?;
|
||||
let float_column = cast
|
||||
.f32()
|
||||
.with_context(|| format!("Column '{name}' is not a float32 column"))?;
|
||||
Ok(float_column
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or(default))
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl POIData {
|
||||
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
|
||||
info!("Loading POI data from {:?}...", parquet_path);
|
||||
|
||||
let df = LazyFrame::scan_parquet(parquet_path, Default::default())
|
||||
.context("Failed to scan POI parquet")?
|
||||
.collect()
|
||||
.context("Failed to read POI parquet")?;
|
||||
|
||||
let row_count = df.height();
|
||||
info!("Loaded {} POIs", row_count);
|
||||
|
||||
let id: Vec<String> = extract_str_col(&df, "id")?;
|
||||
let name = extract_str_col(&df, "name")?;
|
||||
let category_raw = extract_str_col(&df, "category")?;
|
||||
let group_raw = extract_str_col(&df, "group")?;
|
||||
let lat = extract_f32_col(&df, "lat", 0.0)?;
|
||||
let lng = extract_f32_col(&df, "lng", 0.0)?;
|
||||
let emoji_raw = extract_str_col(&df, "emoji")?;
|
||||
|
||||
let category = InternedColumn::build(&category_raw);
|
||||
let group = InternedColumn::build(&group_raw);
|
||||
let emoji = InternedColumn::build(&emoji_raw);
|
||||
|
||||
info!(
|
||||
category_unique = category.values.len(),
|
||||
group_unique = group.values.len(),
|
||||
emoji_unique = emoji.values.len(),
|
||||
"POI string columns interned"
|
||||
);
|
||||
|
||||
// Assign a deterministic pseudo-random priority to each row.
|
||||
// This ensures the same POIs are selected across requests,
|
||||
// preventing visual "shuffling" when panning the map.
|
||||
let priority = generate_priorities(row_count);
|
||||
|
||||
info!("POI data loading complete.");
|
||||
|
||||
Ok(POIData {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
group,
|
||||
lat,
|
||||
lng,
|
||||
emoji,
|
||||
priority,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build category groups from the loaded POI data, validated against POI_GROUP_ORDER.
|
||||
pub fn category_groups(&self) -> anyhow::Result<Vec<POICategoryGroup>> {
|
||||
let mut group_cats: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
let num_pois = self.category.indices.len();
|
||||
for row in 0..num_pois {
|
||||
let category = self.category.get(row).to_string();
|
||||
let group = self.group.get(row).to_string();
|
||||
group_cats.entry(group).or_default().insert(category);
|
||||
}
|
||||
|
||||
// Validate that data groups match the hardcoded order exactly
|
||||
let expected: HashSet<&str> = POI_GROUP_ORDER.iter().copied().collect();
|
||||
let actual: HashSet<&str> = group_cats.keys().map(|key| key.as_str()).collect();
|
||||
let missing_from_data: Vec<&&str> = expected.difference(&actual).collect();
|
||||
let missing_from_order: Vec<&&str> = actual.difference(&expected).collect();
|
||||
if !missing_from_data.is_empty() || !missing_from_order.is_empty() {
|
||||
bail!(
|
||||
"POI group mismatch!\n In POI_GROUP_ORDER but not in data: {:?}\n In data but not in POI_GROUP_ORDER: {:?}",
|
||||
missing_from_data, missing_from_order
|
||||
);
|
||||
}
|
||||
|
||||
POI_GROUP_ORDER
|
||||
.iter()
|
||||
.map(|group_name| {
|
||||
let name = group_name.to_string();
|
||||
let mut categories: Vec<String> = group_cats
|
||||
.remove(&name)
|
||||
.context("POI group validated but missing from map")?
|
||||
.into_iter()
|
||||
.collect();
|
||||
categories.sort();
|
||||
Ok(POICategoryGroup { name, categories })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
179
server-rs/src/data/postcodes.rs
Normal file
179
server-rs/src/data/postcodes.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
use anyhow::Context;
|
||||
use rayon::prelude::*;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// GeoJSON structures for parsing postcode boundary files
|
||||
#[derive(Deserialize)]
|
||||
struct FeatureCollection {
|
||||
features: Vec<Feature>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Feature {
|
||||
geometry: Geometry,
|
||||
properties: Properties,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum Geometry {
|
||||
Polygon {
|
||||
coordinates: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
MultiPolygon {
|
||||
coordinates: Vec<Vec<Vec<[f64; 2]>>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Properties {
|
||||
postcodes: String,
|
||||
}
|
||||
|
||||
/// Postcode boundary data: polygon vertices and spatial index for fast queries.
|
||||
pub struct PostcodeData {
|
||||
/// Postcode strings
|
||||
pub postcodes: Vec<String>,
|
||||
/// All polygon parts per postcode: polygons[i] = list of outer rings
|
||||
/// Single Polygon → 1 ring, MultiPolygon → N rings
|
||||
pub polygons: Vec<Vec<Vec<[f32; 2]>>>,
|
||||
/// Centroid (lat, lon) for lookups
|
||||
pub centroids: Vec<(f32, f32)>,
|
||||
/// Lookup from postcode string to index
|
||||
pub postcode_to_idx: FxHashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl PostcodeData {
|
||||
/// Load postcode boundaries from a directory of GeoJSON files.
|
||||
/// Expects the directory to have a `units/` subdirectory containing .geojson files.
|
||||
pub fn load(dir_path: &Path) -> anyhow::Result<Self> {
|
||||
info!("Loading postcode boundaries from {:?}", dir_path);
|
||||
|
||||
let units_dir = dir_path.join("units");
|
||||
if !units_dir.exists() {
|
||||
anyhow::bail!(
|
||||
"Expected 'units' subdirectory in postcode boundaries path: {:?}",
|
||||
dir_path
|
||||
);
|
||||
}
|
||||
|
||||
let mut postcodes: Vec<String> = Vec::new();
|
||||
let mut polygons: Vec<Vec<Vec<[f32; 2]>>> = Vec::new();
|
||||
let mut centroids: Vec<(f32, f32)> = Vec::new();
|
||||
|
||||
// Read all .geojson files in the units directory
|
||||
let mut entries: Vec<_> = fs::read_dir(&units_dir)
|
||||
.with_context(|| format!("Failed to read directory: {:?}", units_dir))?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "geojson")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
entries.sort_by_key(|entry| entry.path());
|
||||
|
||||
info!(files = entries.len(), "Found GeoJSON files to process");
|
||||
|
||||
// Parse files in parallel
|
||||
let file_results: Vec<_> = entries
|
||||
.into_par_iter()
|
||||
.map(|entry| {
|
||||
let file_path = entry.path();
|
||||
let content = fs::read_to_string(&file_path)
|
||||
.with_context(|| format!("Failed to read file: {:?}", file_path))?;
|
||||
|
||||
let collection: FeatureCollection = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse GeoJSON: {:?}", file_path))?;
|
||||
|
||||
let mut local_postcodes = Vec::new();
|
||||
let mut local_polygons = Vec::new();
|
||||
let mut local_centroids = Vec::new();
|
||||
|
||||
for feature in collection.features {
|
||||
let postcode = feature.properties.postcodes;
|
||||
|
||||
// Extract all outer rings from the geometry
|
||||
let rings: Vec<Vec<[f32; 2]>> = match feature.geometry {
|
||||
Geometry::Polygon { coordinates } => coordinates
|
||||
.first()
|
||||
.map(|ring| {
|
||||
vec![ring
|
||||
.iter()
|
||||
.map(|[lon, lat]| [*lon as f32, *lat as f32])
|
||||
.collect()]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Geometry::MultiPolygon { coordinates } => coordinates
|
||||
.iter()
|
||||
.filter_map(|poly| {
|
||||
poly.first().map(|ring| {
|
||||
ring.iter()
|
||||
.map(|[lon, lat]| [*lon as f32, *lat as f32])
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Compute centroid across all vertices from all rings
|
||||
let total_vertices: usize = rings.iter().map(|ring| ring.len()).sum();
|
||||
let centroid = if total_vertices == 0 {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
let mut sum_lat: f32 = 0.0;
|
||||
let mut sum_lon: f32 = 0.0;
|
||||
for ring in &rings {
|
||||
for &[lon, lat] in ring {
|
||||
sum_lat += lat;
|
||||
sum_lon += lon;
|
||||
}
|
||||
}
|
||||
let count = total_vertices as f32;
|
||||
(sum_lat / count, sum_lon / count)
|
||||
};
|
||||
|
||||
local_postcodes.push(postcode);
|
||||
local_polygons.push(rings);
|
||||
local_centroids.push(centroid);
|
||||
}
|
||||
|
||||
Ok::<_, anyhow::Error>((local_postcodes, local_polygons, local_centroids))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Flatten results
|
||||
for (local_postcodes, local_polygons, local_centroids) in file_results {
|
||||
postcodes.extend(local_postcodes);
|
||||
polygons.extend(local_polygons);
|
||||
centroids.extend(local_centroids);
|
||||
}
|
||||
|
||||
debug!(
|
||||
postcodes = postcodes.len(),
|
||||
"Extracted postcodes from GeoJSON"
|
||||
);
|
||||
|
||||
// Build postcode -> index lookup
|
||||
let mut postcode_to_idx: FxHashMap<String, usize> = FxHashMap::default();
|
||||
for (idx, postcode) in postcodes.iter().enumerate() {
|
||||
postcode_to_idx.insert(postcode.clone(), idx);
|
||||
}
|
||||
|
||||
info!(postcodes = postcodes.len(), "Postcode boundary data ready");
|
||||
|
||||
Ok(PostcodeData {
|
||||
postcodes,
|
||||
polygons,
|
||||
centroids,
|
||||
postcode_to_idx,
|
||||
})
|
||||
}
|
||||
}
|
||||
1073
server-rs/src/data/property.rs
Normal file
1073
server-rs/src/data/property.rs
Normal file
File diff suppressed because it is too large
Load diff
232
server-rs/src/data/travel_time.rs
Normal file
232
server-rs/src/data/travel_time.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use parking_lot::Mutex;
|
||||
use polars::lazy::frame::LazyFrame;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use tracing::info;
|
||||
|
||||
/// Cached postcode → travel_minutes mapping for a single destination file.
|
||||
pub type TravelData = Arc<FxHashMap<String, i16>>;
|
||||
|
||||
/// Simple LRU cache for travel time data, limited to `capacity` entries.
|
||||
struct LruCache {
|
||||
map: FxHashMap<(String, String), TravelData>,
|
||||
order: VecDeque<(String, String)>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl LruCache {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
map: FxHashMap::default(),
|
||||
order: VecDeque::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&mut self, key: &(String, String)) -> Option<TravelData> {
|
||||
if let Some(data) = self.map.get(key) {
|
||||
// Move to front (most recently used)
|
||||
if let Some(pos) = self.order.iter().position(|k| k == key) {
|
||||
self.order.remove(pos);
|
||||
self.order.push_front(key.clone());
|
||||
}
|
||||
Some(data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: (String, String), data: TravelData) {
|
||||
if self.map.contains_key(&key) {
|
||||
self.map.insert(key.clone(), data);
|
||||
if let Some(pos) = self.order.iter().position(|k| k == &key) {
|
||||
self.order.remove(pos);
|
||||
}
|
||||
self.order.push_front(key);
|
||||
} else {
|
||||
while self.map.len() >= self.capacity {
|
||||
if let Some(old_key) = self.order.pop_back() {
|
||||
self.map.remove(&old_key);
|
||||
}
|
||||
}
|
||||
self.map.insert(key.clone(), data);
|
||||
self.order.push_front(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages on-demand loading and caching of precomputed travel time parquet files.
|
||||
///
|
||||
/// Directory structure: `{base_dir}/{mode}/{slug}.parquet`
|
||||
/// Each parquet file has columns: `pcds` (String), `travel_minutes` (Int16).
|
||||
pub struct TravelTimeStore {
|
||||
base_dir: PathBuf,
|
||||
/// Available transport modes (subdirectory names, e.g., "bicycle")
|
||||
pub available_modes: Vec<String>,
|
||||
/// mode → set of destination slugs (filenames without .parquet)
|
||||
pub destinations: FxHashMap<String, FxHashSet<String>>,
|
||||
cache: Mutex<LruCache>,
|
||||
}
|
||||
|
||||
impl TravelTimeStore {
|
||||
/// Scan the travel-times directory to discover available modes and destinations.
|
||||
pub fn load(base_dir: &Path, cache_capacity: usize) -> anyhow::Result<Self> {
|
||||
let mut available_modes = Vec::new();
|
||||
let mut destinations: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
|
||||
|
||||
for entry in std::fs::read_dir(base_dir)
|
||||
.with_context(|| format!("Failed to read travel-times dir: {}", base_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mode = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
let mut slugs = FxHashSet::default();
|
||||
for file_entry in std::fs::read_dir(&path)
|
||||
.with_context(|| format!("Failed to read mode dir: {}", path.display()))?
|
||||
{
|
||||
let file_entry = file_entry?;
|
||||
let file_name = file_entry.file_name();
|
||||
let file_name = file_name.to_string_lossy();
|
||||
if file_name.ends_with(".parquet") {
|
||||
let slug = file_name.trim_end_matches(".parquet").to_string();
|
||||
slugs.insert(slug);
|
||||
}
|
||||
}
|
||||
|
||||
if !slugs.is_empty() {
|
||||
info!(
|
||||
mode = mode.as_str(),
|
||||
destinations = slugs.len(),
|
||||
"Travel time mode discovered"
|
||||
);
|
||||
available_modes.push(mode.clone());
|
||||
destinations.insert(mode, slugs);
|
||||
}
|
||||
}
|
||||
|
||||
available_modes.sort();
|
||||
|
||||
Ok(Self {
|
||||
base_dir: base_dir.to_path_buf(),
|
||||
available_modes,
|
||||
destinations,
|
||||
cache: Mutex::new(LruCache::new(cache_capacity)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load travel time data for a given mode and destination slug.
|
||||
/// Returns a cached or freshly-loaded postcode → travel_minutes mapping.
|
||||
pub fn get(&self, mode: &str, slug: &str) -> anyhow::Result<TravelData> {
|
||||
let key = (mode.to_string(), slug.to_string());
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let mut cache = self.cache.lock();
|
||||
if let Some(data) = cache.get(&key) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Load from file (no lock held — harmless if two threads load the same file)
|
||||
let path = self
|
||||
.base_dir
|
||||
.join(mode)
|
||||
.join(format!("{}.parquet", slug));
|
||||
if !path.exists() {
|
||||
bail!("Travel time file not found: {}", path.display());
|
||||
}
|
||||
|
||||
let df = LazyFrame::scan_parquet(&path, Default::default())
|
||||
.with_context(|| format!("Failed to scan: {}", path.display()))?
|
||||
.collect()
|
||||
.with_context(|| format!("Failed to read: {}", path.display()))?;
|
||||
|
||||
let postcodes = df
|
||||
.column("pcds")
|
||||
.context("Missing 'pcds' column")?
|
||||
.str()
|
||||
.context("'pcds' is not string")?;
|
||||
let minutes = df
|
||||
.column("travel_minutes")
|
||||
.context("Missing 'travel_minutes' column")?
|
||||
.i16()
|
||||
.context("'travel_minutes' is not i16")?;
|
||||
|
||||
let mut map = FxHashMap::default();
|
||||
map.reserve(df.height());
|
||||
for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) {
|
||||
if let (Some(pc), Some(min)) = (pc, min) {
|
||||
map.insert(pc.to_string(), min);
|
||||
}
|
||||
}
|
||||
|
||||
let data: TravelData = Arc::new(map);
|
||||
|
||||
// Insert into cache
|
||||
{
|
||||
let mut cache = self.cache.lock();
|
||||
cache.insert(key, data.clone());
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Check if a mode + slug combination is available.
|
||||
pub fn has_destination(&self, mode: &str, slug: &str) -> bool {
|
||||
self.destinations
|
||||
.get(mode)
|
||||
.map(|slugs| slugs.contains(slug))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Slugify a place name to match travel time file naming convention.
|
||||
/// "Abbey Hey" → "abbey-hey", "A'Bhuaile Ghlas" → "a-bhuaile-ghlas"
|
||||
pub fn slugify(name: &str) -> String {
|
||||
let mut result = String::with_capacity(name.len());
|
||||
let mut last_was_hyphen = true; // Start true to skip leading hyphens
|
||||
for ch in name.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
result.push(ch.to_ascii_lowercase());
|
||||
last_was_hyphen = false;
|
||||
} else if !last_was_hyphen {
|
||||
result.push('-');
|
||||
last_was_hyphen = true;
|
||||
}
|
||||
}
|
||||
if result.ends_with('-') {
|
||||
result.pop();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugify_basic() {
|
||||
assert_eq!(slugify("Abbey Hey"), "abbey-hey");
|
||||
assert_eq!(slugify("Abbots Bickington"), "abbots-bickington");
|
||||
assert_eq!(slugify("London"), "london");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_special_chars() {
|
||||
assert_eq!(slugify("A'Bhuaile Ghlas"), "a-bhuaile-ghlas");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_edges() {
|
||||
assert_eq!(slugify(" Hello "), "hello");
|
||||
assert_eq!(slugify("Abbey"), "abbey");
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,8 @@ pub struct FeatureConfig {
|
|||
|
||||
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
|
||||
/// p1/p99 are snapped to integer boundaries before binning.
|
||||
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
|
||||
pub const INTEGER_BIN_FEATURES: &[&str] =
|
||||
&["Number of bedrooms & living rooms", "Bedrooms", "Bathrooms"];
|
||||
|
||||
pub struct FeatureGroup {
|
||||
pub name: &'static str,
|
||||
|
|
@ -68,6 +69,9 @@ pub const IGNORED_COLUMNS: &[&str] = &[
|
|||
"Is construction date approximate",
|
||||
"Current energy rating",
|
||||
"Potential energy rating",
|
||||
"Property sub-type",
|
||||
"Listing URL",
|
||||
"Price qualifier",
|
||||
];
|
||||
|
||||
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||
|
|
@ -221,6 +225,81 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
raw: true,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Asking price",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 2_000_000.0,
|
||||
},
|
||||
step: 10000.0,
|
||||
description: "Listed asking price for properties currently for sale",
|
||||
detail: "The advertised asking price for properties currently listed for sale on online property portals. Only populated for 'For sale' listings; null for historical sales and rentals.",
|
||||
source: "online-listings",
|
||||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Asking rent (monthly)",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 10_000.0,
|
||||
},
|
||||
step: 50.0,
|
||||
description: "Listed monthly rent for properties currently for rent",
|
||||
detail: "The advertised rental price normalized to monthly for properties currently listed for rent on online property portals. Weekly rents are converted (×52/12), yearly (/12), daily (×365.25/12), and quarterly (/3). Only populated for 'For rent' listings.",
|
||||
source: "online-listings",
|
||||
prefix: "£",
|
||||
suffix: "/mo",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Bedrooms",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Number of bedrooms from online listing",
|
||||
detail: "Number of bedrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
|
||||
source: "online-listings",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Bathrooms",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Number of bathrooms from online listing",
|
||||
detail: "Number of bathrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
|
||||
source: "online-listings",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Listing date",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 2006.0,
|
||||
max: 2026.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Date the property was first listed online",
|
||||
detail: "The date when the property listing first appeared on the online property portal. Stored as a datetime; converted to fractional year for filtering. Only populated for online listings.",
|
||||
source: "online-listings",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
FeatureGroup {
|
||||
|
|
@ -442,7 +521,43 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
],
|
||||
},
|
||||
FeatureGroup {
|
||||
|
||||
FeatureGroup {
|
||||
name: "Crime summary",
|
||||
features: &[
|
||||
FeatureConfig {
|
||||
name: "Serious crime (avg/yr)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Aggregate of serious crime categories per year",
|
||||
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Minor crime (avg/yr)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Aggregate of minor crime categories per year",
|
||||
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
FeatureGroup {
|
||||
name: "Crime",
|
||||
features: &[
|
||||
FeatureConfig {
|
||||
|
|
@ -655,36 +770,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Serious crime (avg/yr)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Aggregate of serious crime categories per year",
|
||||
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Minor crime (avg/yr)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Aggregate of minor crime categories per year",
|
||||
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.",
|
||||
source: "crime",
|
||||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
FeatureGroup {
|
||||
|
|
@ -858,6 +943,13 @@ pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
|
|||
EnumFeatureGroup {
|
||||
name: "Property",
|
||||
features: &[
|
||||
EnumFeatureConfig {
|
||||
name: "Listing status",
|
||||
order: Some(&["Historical sale", "For sale", "For rent"]),
|
||||
description: "Whether the property is from historical sales, currently for sale, or for rent",
|
||||
detail: "Indicates the source of the property record: 'Historical sale' from HM Land Registry Price Paid data, 'For sale' from current online buy listings, or 'For rent' from current online rental listings.",
|
||||
source: "online-listings",
|
||||
},
|
||||
EnumFeatureConfig {
|
||||
name: "Leashold/Freehold",
|
||||
order: Some(&["Freehold", "Leasehold"]),
|
||||
|
|
|
|||
55
server-rs/src/licensing.rs
Normal file
55
server-rs/src/licensing.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::PocketBaseUser;
|
||||
use crate::consts::FREE_ZONE_BOUNDS;
|
||||
|
||||
/// 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.
|
||||
pub fn check_license_bounds(
|
||||
user: &Option<PocketBaseUser>,
|
||||
bounds: (f64, f64, f64, f64),
|
||||
) -> Result<(), (StatusCode, axum::response::Response)> {
|
||||
// Licensed users and admins can query anywhere
|
||||
if let Some(u) = user {
|
||||
if u.is_admin || u.subscription == "licensed" {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let (south, west, north, east) = bounds;
|
||||
let (fz_south, fz_west, fz_north, fz_east) = FREE_ZONE_BOUNDS;
|
||||
|
||||
// Check if requested bounds are fully within the free zone
|
||||
if south >= fz_south && west >= fz_west && north <= fz_north && east <= fz_east {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"error": "license_required",
|
||||
"message": "A license is required to view data outside inner London",
|
||||
"free_zone": {
|
||||
"south": fz_south,
|
||||
"west": fz_west,
|
||||
"north": fz_north,
|
||||
"east": fz_east,
|
||||
}
|
||||
});
|
||||
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
(StatusCode::FORBIDDEN, axum::Json(body)).into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Convenience wrapper that takes a point (lat, lon) instead of bounds.
|
||||
/// Used for endpoints that operate on a single location (e.g. postcode stats).
|
||||
pub fn check_license_point(
|
||||
user: &Option<PocketBaseUser>,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
) -> Result<(), (StatusCode, axum::response::Response)> {
|
||||
check_license_bounds(user, (lat, lon, lat, lon))
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ mod auth;
|
|||
mod consts;
|
||||
mod data;
|
||||
mod features;
|
||||
mod licensing;
|
||||
mod metrics;
|
||||
mod og_middleware;
|
||||
pub mod parsing;
|
||||
|
|
@ -13,15 +14,17 @@ pub mod utils;
|
|||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use axum::middleware;
|
||||
use axum::routing::{any, get, post};
|
||||
use axum::routing::{any, get, patch, post};
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
use tower::limit::ConcurrencyLimitLayer;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info;
|
||||
|
|
@ -52,7 +55,7 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
tiles: PathBuf,
|
||||
|
||||
/// Path to the frontend dist directory
|
||||
/// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted)
|
||||
#[arg(long)]
|
||||
dist: Option<PathBuf>,
|
||||
|
||||
|
|
@ -70,11 +73,11 @@ struct Cli {
|
|||
|
||||
/// PocketBase superuser email (for auto-creating collections at startup)
|
||||
#[arg(long, env = "POCKETBASE_ADMIN_EMAIL")]
|
||||
pocketbase_admin_email: Option<String>,
|
||||
pocketbase_admin_email: String,
|
||||
|
||||
/// PocketBase superuser password (for auto-creating collections at startup)
|
||||
#[arg(long, env = "POCKETBASE_ADMIN_PASSWORD")]
|
||||
pocketbase_admin_password: Option<String>,
|
||||
pocketbase_admin_password: String,
|
||||
|
||||
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
|
||||
#[arg(long, env = "OLLAMA_URL")]
|
||||
|
|
@ -84,13 +87,41 @@ struct Cli {
|
|||
#[arg(long, env = "OLLAMA_MODEL")]
|
||||
ollama_model: String,
|
||||
|
||||
/// R5 routing service URL for all travel times (e.g. http://r5:8003)
|
||||
#[arg(long, env = "R5_URL")]
|
||||
r5_url: Option<String>,
|
||||
/// Path to precomputed travel times directory (contains mode subdirs with parquet files)
|
||||
#[arg(long, env = "TRAVEL_TIMES")]
|
||||
travel_times: PathBuf,
|
||||
|
||||
/// Google Maps API key for Street View metadata lookups
|
||||
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
|
||||
google_maps_api_key: String,
|
||||
|
||||
/// Stripe secret key for checkout sessions
|
||||
#[arg(long, env = "STRIPE_SECRET_KEY")]
|
||||
stripe_secret_key: String,
|
||||
|
||||
/// Stripe webhook signing secret for verifying webhook signatures
|
||||
#[arg(long, env = "STRIPE_WEBHOOK_SECRET")]
|
||||
stripe_webhook_secret: String,
|
||||
|
||||
/// Stripe Coupon ID applied when a referral code is used
|
||||
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
|
||||
stripe_referral_coupon_id: String,
|
||||
|
||||
/// Google OAuth client ID for PocketBase SSO
|
||||
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
|
||||
google_oauth_client_id: String,
|
||||
|
||||
/// Google OAuth client secret for PocketBase SSO
|
||||
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
|
||||
google_oauth_client_secret: String,
|
||||
|
||||
/// Apple OAuth client ID for PocketBase SSO
|
||||
#[arg(long, env = "APPLE_OAUTH_CLIENT_ID")]
|
||||
apple_oauth_client_id: String,
|
||||
|
||||
/// Apple OAuth client secret for PocketBase SSO
|
||||
#[arg(long, env = "APPLE_OAUTH_CLIENT_SECRET")]
|
||||
apple_oauth_client_secret: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -212,19 +243,23 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let poi_category_groups = poi_data.category_groups()?;
|
||||
|
||||
// Read index.html at startup for crawler OG injection
|
||||
let (frontend_dist, index_html) = if let Some(dist) = cli.dist {
|
||||
// Read index.html at startup for crawler OG injection (only when --dist is provided)
|
||||
let index_html = if let Some(ref dist) = cli.dist {
|
||||
let index_path = dist.join("index.html");
|
||||
let html = std::fs::read_to_string(&index_path)
|
||||
.with_context(|| format!("Failed to read {}", index_path.display()))?;
|
||||
info!("Loaded index.html for OG injection");
|
||||
(Some(dist), Some(html))
|
||||
Some(html)
|
||||
} else {
|
||||
info!("No --dist provided, static serving and OG injection disabled");
|
||||
(None, None)
|
||||
info!("No --dist provided; static serving and OG injection disabled");
|
||||
None
|
||||
};
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
info!("Screenshot service configured: {}", cli.screenshot_url);
|
||||
|
||||
|
|
@ -247,23 +282,46 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
info!("PocketBase configured: {}", cli.pocketbase_url);
|
||||
|
||||
if let (Some(ref email), Some(ref password)) =
|
||||
(&cli.pocketbase_admin_email, &cli.pocketbase_admin_password)
|
||||
{
|
||||
pocketbase::ensure_collections(&http_client, &cli.pocketbase_url, email, password).await?;
|
||||
} else {
|
||||
info!("PocketBase admin credentials not set — skipping collection auto-creation");
|
||||
}
|
||||
pocketbase::ensure_collections(
|
||||
&http_client,
|
||||
&cli.pocketbase_url,
|
||||
&cli.pocketbase_admin_email,
|
||||
&cli.pocketbase_admin_password,
|
||||
)
|
||||
.await?;
|
||||
pocketbase::ensure_oauth_providers(
|
||||
&http_client,
|
||||
&cli.pocketbase_url,
|
||||
&cli.pocketbase_admin_email,
|
||||
&cli.pocketbase_admin_password,
|
||||
&cli.public_url,
|
||||
&cli.google_oauth_client_id,
|
||||
&cli.google_oauth_client_secret,
|
||||
&cli.apple_oauth_client_id,
|
||||
&cli.apple_oauth_client_secret,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Ollama configured: {} (model: {})",
|
||||
cli.ollama_url, cli.ollama_model
|
||||
);
|
||||
if let Some(ref url) = cli.r5_url {
|
||||
info!("R5 routing service configured: {}", url);
|
||||
} else {
|
||||
info!("R5 routing service not configured (travel time queries disabled)");
|
||||
let tt_path = &cli.travel_times;
|
||||
if !tt_path.exists() {
|
||||
bail!(
|
||||
"Travel times directory not found: {}",
|
||||
tt_path.display()
|
||||
);
|
||||
}
|
||||
info!("Loading travel time data from {}", tt_path.display());
|
||||
let travel_time_store = {
|
||||
let store = data::TravelTimeStore::load(tt_path, 50)?;
|
||||
info!(
|
||||
modes = store.available_modes.len(),
|
||||
"Travel time store loaded"
|
||||
);
|
||||
Arc::new(store)
|
||||
};
|
||||
|
||||
let token_cache = Arc::new(auth::TokenCache::new());
|
||||
|
||||
|
|
@ -286,19 +344,30 @@ async fn main() -> anyhow::Result<()> {
|
|||
index_html,
|
||||
http_client,
|
||||
pocketbase_url: cli.pocketbase_url,
|
||||
pocketbase_admin_email: cli.pocketbase_admin_email,
|
||||
pocketbase_admin_password: cli.pocketbase_admin_password,
|
||||
ollama_url: cli.ollama_url,
|
||||
ollama_model: cli.ollama_model,
|
||||
r5_url: cli.r5_url,
|
||||
travel_time_store,
|
||||
token_cache,
|
||||
ai_filters_schema,
|
||||
ai_filters_system_prompt,
|
||||
google_maps_api_key: cli.google_maps_api_key,
|
||||
stripe_secret_key: cli.stripe_secret_key,
|
||||
stripe_webhook_secret: cli.stripe_webhook_secret,
|
||||
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
.allow_origin(
|
||||
state
|
||||
.public_url
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("public_url must be a valid header value"),
|
||||
)
|
||||
.allow_methods(AllowMethods::mirror_request())
|
||||
.allow_headers(AllowHeaders::mirror_request())
|
||||
.allow_credentials(true);
|
||||
|
||||
let state_features = state.clone();
|
||||
let state_hexagons = state.clone();
|
||||
|
|
@ -319,6 +388,15 @@ async fn main() -> anyhow::Result<()> {
|
|||
let state_short_url = state.clone();
|
||||
let state_ai_filters = state.clone();
|
||||
let state_streetview = state.clone();
|
||||
let state_subscription = state.clone();
|
||||
let state_newsletter = state.clone();
|
||||
let state_travel_modes = state.clone();
|
||||
let state_checkout = state.clone();
|
||||
let state_stripe_webhook = state.clone();
|
||||
let state_pricing = state.clone();
|
||||
let state_invites_create = state.clone();
|
||||
let state_invite_get = state.clone();
|
||||
let state_redeem_invite = state.clone();
|
||||
|
||||
let api = Router::new()
|
||||
.route(
|
||||
|
|
@ -327,11 +405,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/hexagons",
|
||||
get(move |query| routes::get_hexagons(state_hexagons.clone(), query)),
|
||||
get(move |ext, query| routes::get_hexagons(state_hexagons.clone(), ext, query)),
|
||||
)
|
||||
.route(
|
||||
"/api/postcodes",
|
||||
get(move |query| routes::get_postcodes(state_postcodes.clone(), query)),
|
||||
get(move |ext, query| routes::get_postcodes(state_postcodes.clone(), ext, query)),
|
||||
)
|
||||
.route(
|
||||
"/api/postcode/{postcode}",
|
||||
|
|
@ -349,19 +427,23 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/api/places",
|
||||
get(move |query| routes::get_places(state_places.clone(), query)),
|
||||
)
|
||||
.route(
|
||||
"/api/travel-modes",
|
||||
get(move || routes::get_travel_modes(state_travel_modes.clone())),
|
||||
)
|
||||
.route(
|
||||
"/api/hexagon-properties",
|
||||
get(move |query| {
|
||||
routes::get_hexagon_properties(state_hexagon_properties.clone(), query)
|
||||
get(move |ext, query| {
|
||||
routes::get_hexagon_properties(state_hexagon_properties.clone(), ext, query)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/hexagon-stats",
|
||||
get(move |query| routes::get_hexagon_stats(state_hexagon_stats.clone(), query)),
|
||||
get(move |ext, query| routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)),
|
||||
)
|
||||
.route(
|
||||
"/api/postcode-stats",
|
||||
get(move |query| routes::get_postcode_stats(state_postcode_stats.clone(), query)),
|
||||
get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)),
|
||||
)
|
||||
.route(
|
||||
"/api/screenshot",
|
||||
|
|
@ -369,12 +451,14 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/export",
|
||||
get(move |query| routes::get_export(state_export.clone(), query)),
|
||||
get(move |ext, query| routes::get_export(state_export.clone(), ext, query))
|
||||
.layer(ConcurrencyLimitLayer::new(3)),
|
||||
)
|
||||
.route("/api/me", get(routes::get_me))
|
||||
.route(
|
||||
"/api/area-summary",
|
||||
post(move |body| routes::post_area_summary(state_area_summary.clone(), body)),
|
||||
post(move |body| routes::post_area_summary(state_area_summary.clone(), body))
|
||||
.layer(ConcurrencyLimitLayer::new(5)),
|
||||
)
|
||||
.route(
|
||||
"/api/shorten",
|
||||
|
|
@ -382,12 +466,54 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/ai-filters",
|
||||
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body)),
|
||||
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body))
|
||||
.layer(ConcurrencyLimitLayer::new(5)),
|
||||
)
|
||||
.route(
|
||||
"/api/streetview",
|
||||
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
|
||||
)
|
||||
.route(
|
||||
"/api/subscription",
|
||||
patch(move |ext, body| {
|
||||
routes::patch_subscription(state_subscription.clone(), ext, body)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/newsletter",
|
||||
patch(move |ext, body| {
|
||||
routes::patch_newsletter(state_newsletter.clone(), ext, body)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/pricing",
|
||||
get(move || routes::get_pricing(state_pricing.clone())),
|
||||
)
|
||||
.route(
|
||||
"/api/checkout",
|
||||
post(move |ext, body| routes::post_checkout(state_checkout.clone(), ext, body))
|
||||
.layer(ConcurrencyLimitLayer::new(10)),
|
||||
)
|
||||
.route(
|
||||
"/api/stripe-webhook",
|
||||
post(move |headers, body| {
|
||||
routes::post_stripe_webhook(state_stripe_webhook.clone(), headers, body)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/invites",
|
||||
post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
|
||||
)
|
||||
.route(
|
||||
"/api/invite/{code}",
|
||||
get(move |ext, path| routes::get_invite(state_invite_get.clone(), ext, path)),
|
||||
)
|
||||
.route(
|
||||
"/api/redeem-invite",
|
||||
post(move |ext, body| {
|
||||
routes::post_redeem_invite(state_redeem_invite.clone(), ext, body)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/s/{code}",
|
||||
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
|
||||
|
|
@ -396,6 +522,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
// Add tile routes
|
||||
let reader_tile = tile_reader.clone();
|
||||
let reader_style = tile_reader.clone();
|
||||
let public_url_tiles = state.public_url.clone();
|
||||
let api = api
|
||||
.route(
|
||||
"/api/tiles/{z}/{x}/{y}",
|
||||
|
|
@ -403,8 +530,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/tiles/style.json",
|
||||
get(move |headers, query| {
|
||||
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
|
||||
get(move |query| {
|
||||
let pu = public_url_tiles.clone();
|
||||
routes::get_style(axum::extract::State(reader_style.clone()), pu, query)
|
||||
}),
|
||||
)
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
|
|
@ -417,15 +545,13 @@ async fn main() -> anyhow::Result<()> {
|
|||
any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
|
||||
);
|
||||
|
||||
let app = if let Some(ref dist) = frontend_dist {
|
||||
let app = if let Some(ref dist) = cli.dist {
|
||||
api.fallback_service(
|
||||
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
|
||||
)
|
||||
} else {
|
||||
api
|
||||
};
|
||||
|
||||
let app = app
|
||||
}
|
||||
.layer(middleware::from_fn(metrics::track_metrics))
|
||||
.layer(middleware::from_fn(auth::auth_middleware))
|
||||
.layer(middleware::from_fn(
|
||||
|
|
|
|||
|
|
@ -1,28 +1,38 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
/// Parse an optional `?fields=` query param into feature indices for selective aggregation.
|
||||
/// Returns `None` if fields is `None` (all features included), or `Some(indices)` if specified.
|
||||
/// Returns an error if any field name is unknown.
|
||||
pub fn parse_field_indices(
|
||||
fields: Option<&str>,
|
||||
name_to_index: &FxHashMap<String, usize>,
|
||||
) -> Option<Vec<usize>> {
|
||||
fields.map(|fields_str| {
|
||||
if fields_str.is_empty() {
|
||||
return Vec::new();
|
||||
) -> Result<Option<Vec<usize>>, (StatusCode, String)> {
|
||||
let Some(fields_str) = fields else {
|
||||
return Ok(None);
|
||||
};
|
||||
if fields_str.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut indices = Vec::new();
|
||||
for name in fields_str.split(',') {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
fields_str
|
||||
.split(',')
|
||||
.filter_map(|name| {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
name_to_index.get(name).copied()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
match name_to_index.get(name) {
|
||||
Some(&idx) => indices.push(idx),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Unknown field: {}", name),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(indices))
|
||||
}
|
||||
|
||||
/// Parse an optional `?fields=` query param into a HashSet for stats filtering.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthResponse {
|
||||
|
|
@ -79,7 +79,7 @@ impl Field {
|
|||
}
|
||||
}
|
||||
|
||||
async fn auth_superuser(
|
||||
pub async fn auth_superuser(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
|
|
@ -177,7 +177,82 @@ async fn find_users_collection_id(
|
|||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase.
|
||||
/// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection.
|
||||
/// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields.
|
||||
async fn ensure_user_fields(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = format!("{base_url}/api/collections/users");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to fetch users collection ({status}): {text}");
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let fields = body["fields"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("users collection has no fields array"))?;
|
||||
|
||||
let has_is_admin = fields.iter().any(|f| f["name"] == "is_admin");
|
||||
let has_subscription = fields.iter().any(|f| f["name"] == "subscription");
|
||||
let has_newsletter = fields.iter().any(|f| f["name"] == "newsletter");
|
||||
|
||||
if has_is_admin && has_subscription && has_newsletter {
|
||||
info!("PocketBase users collection already has is_admin, subscription, and newsletter fields");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut new_fields = fields.clone();
|
||||
|
||||
if !has_is_admin {
|
||||
new_fields.push(serde_json::json!({
|
||||
"name": "is_admin",
|
||||
"type": "bool",
|
||||
}));
|
||||
}
|
||||
|
||||
if !has_subscription {
|
||||
new_fields.push(serde_json::json!({
|
||||
"name": "subscription",
|
||||
"type": "text",
|
||||
}));
|
||||
}
|
||||
|
||||
if !has_newsletter {
|
||||
new_fields.push(serde_json::json!({
|
||||
"name": "newsletter",
|
||||
"type": "bool",
|
||||
}));
|
||||
}
|
||||
|
||||
let patch_resp = client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "fields": new_fields }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !patch_resp.status().is_success() {
|
||||
let status = patch_resp.status();
|
||||
let text = patch_resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to patch users collection ({status}): {text}");
|
||||
}
|
||||
|
||||
info!("Added missing fields to PocketBase users collection");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase,
|
||||
/// and that the `users` collection has `is_admin` and `subscription` fields.
|
||||
/// Authenticates as superuser, checks existing collections, and creates any that are missing.
|
||||
pub async fn ensure_collections(
|
||||
client: &Client,
|
||||
|
|
@ -190,6 +265,8 @@ pub async fn ensure_collections(
|
|||
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
|
||||
let existing = list_collections(client, base_url, &token).await?;
|
||||
|
||||
ensure_user_fields(client, base_url, &token).await?;
|
||||
|
||||
if !existing.iter().any(|n| n == "saved_searches") {
|
||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
||||
create_collection(
|
||||
|
|
@ -212,6 +289,28 @@ pub async fn ensure_collections(
|
|||
info!("PocketBase collection 'saved_searches' already exists");
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "invites") {
|
||||
create_collection(
|
||||
client,
|
||||
base_url,
|
||||
&token,
|
||||
CreateCollection {
|
||||
name: "invites".to_string(),
|
||||
r#type: "base".to_string(),
|
||||
fields: vec![
|
||||
Field::text("code", true),
|
||||
Field::text("created_by", true),
|
||||
Field::text("invite_type", true),
|
||||
Field::text("used_by_id", false),
|
||||
Field::text("used_at", false),
|
||||
],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
info!("PocketBase collection 'invites' already exists");
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "short_urls") {
|
||||
create_collection(
|
||||
client,
|
||||
|
|
@ -233,3 +332,94 @@ pub async fn ensure_collections(
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure Google and Apple OAuth2 providers in PocketBase settings.
|
||||
/// Also sets `meta.appUrl` so OAuth callbacks route to `{public_url}/pb`.
|
||||
pub async fn ensure_oauth_providers(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
admin_email: &str,
|
||||
admin_password: &str,
|
||||
public_url: &str,
|
||||
google_client_id: &str,
|
||||
google_client_secret: &str,
|
||||
apple_client_id: &str,
|
||||
apple_client_secret: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
|
||||
|
||||
// GET current settings
|
||||
let settings_url = format!("{base_url}/api/settings");
|
||||
let resp = client
|
||||
.get(&settings_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to fetch PocketBase settings ({status}): {text}");
|
||||
}
|
||||
|
||||
let mut settings: serde_json::Value = resp.json().await?;
|
||||
|
||||
// Set meta.appUrl for OAuth redirect
|
||||
let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
|
||||
if let Some(meta) = settings.get_mut("meta") {
|
||||
meta["appUrl"] = serde_json::json!(app_url);
|
||||
} else {
|
||||
settings["meta"] = serde_json::json!({ "appUrl": app_url });
|
||||
}
|
||||
|
||||
// Update OAuth2 providers
|
||||
let providers = settings
|
||||
.pointer_mut("/oauth2/providers")
|
||||
.and_then(|v| v.as_array_mut());
|
||||
|
||||
if let Some(providers) = providers {
|
||||
for provider in providers.iter_mut() {
|
||||
let name = provider
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match name {
|
||||
"google" => {
|
||||
provider["clientId"] = serde_json::json!(google_client_id);
|
||||
provider["clientSecret"] = serde_json::json!(google_client_secret);
|
||||
provider["enabled"] = serde_json::json!(true);
|
||||
info!("Configured Google OAuth provider");
|
||||
}
|
||||
"apple" => {
|
||||
provider["clientId"] = serde_json::json!(apple_client_id);
|
||||
provider["clientSecret"] = serde_json::json!(apple_client_secret);
|
||||
provider["enabled"] = serde_json::json!(true);
|
||||
info!("Configured Apple OAuth provider");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("PocketBase settings missing oauth2.providers array — cannot configure OAuth");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// PATCH settings back
|
||||
let patch_resp = client
|
||||
.patch(&settings_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&settings)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !patch_resp.status().is_success() {
|
||||
let status = patch_resp.status();
|
||||
let text = patch_resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to update PocketBase settings ({status}): {text}");
|
||||
}
|
||||
|
||||
info!("PocketBase OAuth settings updated (appUrl: {app_url})");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
mod ai_filters;
|
||||
mod area_summary;
|
||||
mod checkout;
|
||||
mod export;
|
||||
mod features;
|
||||
mod hexagon_stats;
|
||||
pub(crate) mod hexagons;
|
||||
mod invites;
|
||||
mod me;
|
||||
mod pb_proxy;
|
||||
mod places;
|
||||
|
|
@ -15,11 +17,17 @@ mod screenshot;
|
|||
mod shorten;
|
||||
mod stats;
|
||||
mod streetview;
|
||||
mod stripe_webhook;
|
||||
mod newsletter;
|
||||
pub(crate) mod pricing;
|
||||
mod subscription;
|
||||
mod tiles;
|
||||
pub(crate) mod travel_time;
|
||||
mod travel_modes;
|
||||
|
||||
pub use ai_filters::{build_ollama_schema, build_system_prompt, post_ai_filters};
|
||||
pub use area_summary::post_area_summary;
|
||||
pub use checkout::post_checkout;
|
||||
pub use export::get_export;
|
||||
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
|
||||
pub use hexagon_stats::get_hexagon_stats;
|
||||
|
|
@ -34,4 +42,10 @@ pub use properties::get_hexagon_properties;
|
|||
pub use screenshot::get_screenshot;
|
||||
pub use shorten::{get_short_url, post_shorten};
|
||||
pub use streetview::get_streetview;
|
||||
pub use invites::{get_invite, post_invites, post_redeem_invite};
|
||||
pub use newsletter::patch_newsletter;
|
||||
pub use pricing::get_pricing;
|
||||
pub use stripe_webhook::post_stripe_webhook;
|
||||
pub use subscription::patch_subscription;
|
||||
pub use tiles::{get_style, get_tile, init_tile_reader};
|
||||
pub use travel_modes::get_travel_modes;
|
||||
|
|
|
|||
178
server-rs/src/routes/checkout.rs
Normal file
178
server-rs/src/routes/checkout.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::pricing::{count_licensed_users, price_for_count};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckoutRequest {
|
||||
referral_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckoutResponse {
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Create a Stripe Checkout session for the lifetime license (or grant for free if in free tier).
|
||||
/// Requires authentication. Optionally accepts a referral code to apply a coupon.
|
||||
pub async fn post_checkout(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<CheckoutRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
let count = match count_licensed_users(&state).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users at checkout: {err}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let price_pence = price_for_count(count);
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
|
||||
// Free tier — grant license directly without Stripe
|
||||
if price_pence == 0 {
|
||||
if let Err(err) = grant_license(&state, &user.id).await {
|
||||
warn!(user_id = %user.id, "Failed to grant free license: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
info!(user_id = %user.id, "Granted free early-bird license");
|
||||
return Json(CheckoutResponse { url: success_url }).into_response();
|
||||
}
|
||||
|
||||
// Paid tier — create Stripe checkout with dynamic price
|
||||
let secret_key = &state.stripe_secret_key;
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let mut form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
];
|
||||
|
||||
// If a referral code is provided and valid, look it up and apply the coupon
|
||||
if let Some(ref code) = req.referral_code {
|
||||
if validate_referral_invite(&state, code).await {
|
||||
form_params.push(("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()));
|
||||
info!(code = %code, "Applying referral coupon to checkout");
|
||||
}
|
||||
}
|
||||
|
||||
let res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse Stripe response: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
let url = body["url"].as_str().unwrap_or_default().to_string();
|
||||
if url.is_empty() {
|
||||
warn!("Stripe session missing URL");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
info!(user_id = %user.id, price_pence, "Created Stripe checkout session");
|
||||
Json(CheckoutResponse { url }).into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Stripe checkout failed ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Stripe request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
|
||||
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||
let resp = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("PocketBase update failed ({status}): {text}");
|
||||
}
|
||||
|
||||
state.token_cache.invalidate_by_user_id(user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a referral invite code exists and is unused.
|
||||
async fn validate_referral_invite(state: &AppState, code: &str) -> bool {
|
||||
// Only allow alphanumeric codes to prevent PocketBase filter injection
|
||||
if code.is_empty()
|
||||
|| code.len() > 20
|
||||
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!(
|
||||
"code=\"{}\" && invite_type=\"referral\" && used_by_id=\"\"",
|
||||
code
|
||||
);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
match state.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
body["totalItems"].as_u64().unwrap_or(0) > 0
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,14 @@ use std::sync::Arc;
|
|||
use axum::extract::Query;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Extension;
|
||||
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Image, Url, Workbook};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
|
||||
use crate::routes::FeatureInfo;
|
||||
use crate::state::AppState;
|
||||
|
|
@ -150,9 +153,14 @@ async fn fetch_screenshot(
|
|||
|
||||
pub async fn get_export(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<ExportParams>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let (south, west, north, east) = require_bounds(params.bounds)?;
|
||||
) -> Result<impl IntoResponse, axum::response::Response> {
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let fields_str = params.fields.clone();
|
||||
|
|
@ -161,7 +169,7 @@ pub async fn get_export(
|
|||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let public_url = state.public_url.clone();
|
||||
|
||||
|
|
@ -269,7 +277,8 @@ pub async fn get_export(
|
|||
let filter_feature_names = extract_filter_feature_names(filters_str.as_deref());
|
||||
|
||||
let field_indices =
|
||||
parse_field_indices(fields_str.as_deref(), &state.feature_name_to_index);
|
||||
parse_field_indices(fields_str.as_deref(), &state.feature_name_to_index)
|
||||
.map_err(|err| err.1)?;
|
||||
|
||||
let all_feature_indices: Vec<usize> = if let Some(ref indices) = field_indices {
|
||||
indices.clone()
|
||||
|
|
@ -564,8 +573,8 @@ pub async fn get_export(
|
|||
Ok(buf)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err))?;
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err).into_response())?;
|
||||
|
||||
Ok((
|
||||
[
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{
|
||||
cell_for_row, h3_cell_bounds, needs_parent, parse_field_set, parse_filters, row_passes_filters,
|
||||
validate_h3_resolution,
|
||||
|
|
@ -70,19 +73,25 @@ pub struct HexagonStatsParams {
|
|||
|
||||
pub async fn get_hexagon_stats(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<HexagonStatsParams>,
|
||||
) -> Result<Json<HexagonStatsResponse>, (StatusCode, String)> {
|
||||
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
|
||||
let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| {
|
||||
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid H3 cell: {}", error),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let cell_u64: u64 = cell.into();
|
||||
|
||||
let resolution = params.resolution;
|
||||
validate_h3_resolution(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);
|
||||
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
|
||||
|
||||
let h3_str = params.h3.clone();
|
||||
let filters_str = params.filters.clone();
|
||||
|
|
@ -91,7 +100,7 @@ pub async fn get_hexagon_stats(
|
|||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
|
@ -164,8 +173,8 @@ pub async fn get_hexagon_stats(
|
|||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
|
||||
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,23 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::aggregation::Aggregator;
|
||||
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::parsing::{
|
||||
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
|
||||
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
|
||||
};
|
||||
use crate::routes::travel_time::fetch_travel_times;
|
||||
use crate::routes::travel_time::TravelTimeAgg;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -27,64 +31,69 @@ pub struct HexagonParams {
|
|||
resolution: u8,
|
||||
bounds: Option<String>,
|
||||
/// Comma-separated filters: `name:min:max,...`
|
||||
/// Rows must have non-NaN values within [min,max] for each filter.
|
||||
filters: Option<String>,
|
||||
/// Comma-separated feature names to include in min/max aggregation.
|
||||
/// When present (even if empty), only listed features are aggregated and written.
|
||||
/// When absent, all features are included (backward compatible).
|
||||
fields: Option<String>,
|
||||
/// Pipe-separated travel time entries: `lat,lon,mode|lat,lon,mode`
|
||||
/// Each entry requests travel time from hex centroids to that destination via the given mode.
|
||||
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
|
||||
/// Each entry requests travel time aggregation for that mode+destination.
|
||||
/// Optional min:max applies as a filter (exclude properties outside range).
|
||||
travel: Option<String>,
|
||||
}
|
||||
|
||||
struct TravelEntry {
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
mode: String,
|
||||
slug: String,
|
||||
filter_min: Option<f32>,
|
||||
filter_max: Option<f32>,
|
||||
}
|
||||
|
||||
const VALID_MODES: &[&str] = &["car", "bicycle", "walking", "transit"];
|
||||
|
||||
/// Parse `travel` param into a list of travel entries.
|
||||
/// Format: `lat,lon,mode|lat,lon,mode`
|
||||
fn parse_travel_entries(s: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
/// Format: `mode:slug` or `mode:slug:min:max`
|
||||
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let mut seen_modes = Vec::new();
|
||||
for segment in s.split('|') {
|
||||
let parts: Vec<&str> = segment.split(',').collect();
|
||||
if parts.len() != 3 {
|
||||
let mut seen_keys = Vec::new();
|
||||
for segment in travel_str.split('|') {
|
||||
let parts: Vec<&str> = segment.split(':').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(format!(
|
||||
"each travel entry must be 'lat,lon,mode', got '{}'",
|
||||
"each travel entry must be 'mode:slug' or 'mode:slug:min:max', got '{}'",
|
||||
segment
|
||||
));
|
||||
}
|
||||
let lat: f64 = parts[0]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel latitude in '{}'", segment))?;
|
||||
let lon: f64 = parts[1]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel longitude in '{}'", segment))?;
|
||||
let mode = parts[2].trim().to_string();
|
||||
if !VALID_MODES.contains(&mode.as_str()) {
|
||||
return Err(format!(
|
||||
"invalid travel mode '{}', must be one of: {}",
|
||||
mode,
|
||||
VALID_MODES.join(", ")
|
||||
));
|
||||
let mode = parts[0].trim().to_string();
|
||||
let slug = parts[1].trim().to_string();
|
||||
|
||||
let (filter_min, filter_max) = if parts.len() >= 4 {
|
||||
let min: f32 = parts[2]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
|
||||
let max: f32 = parts[3]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
|
||||
(Some(min), Some(max))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let key = format!("{}:{}", mode, slug);
|
||||
if seen_keys.contains(&key) {
|
||||
return Err(format!("duplicate travel entry '{}'", key));
|
||||
}
|
||||
if seen_modes.contains(&mode) {
|
||||
return Err(format!("duplicate travel mode '{}'", mode));
|
||||
}
|
||||
seen_modes.push(mode.clone());
|
||||
entries.push(TravelEntry { lat, lon, mode });
|
||||
seen_keys.push(key);
|
||||
entries.push(TravelEntry {
|
||||
mode,
|
||||
slug,
|
||||
filter_min,
|
||||
filter_max,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_feature_maps(
|
||||
groups: &FxHashMap<u64, Aggregator>,
|
||||
min_keys: &[String],
|
||||
|
|
@ -92,7 +101,9 @@ fn build_feature_maps(
|
|||
avg_keys: &[String],
|
||||
num_features: usize,
|
||||
indices: Option<&[usize]>,
|
||||
query_bounds: (f64, f64, f64, f64), // (south, west, north, east)
|
||||
query_bounds: (f64, f64, f64, f64),
|
||||
travel_aggs: &[FxHashMap<u64, TravelTimeAgg>],
|
||||
travel_field_keys: &[String],
|
||||
) -> Vec<Map<String, Value>> {
|
||||
let mut features = Vec::with_capacity(groups.len());
|
||||
let (q_south, q_west, q_north, q_east) = query_bounds;
|
||||
|
|
@ -143,6 +154,25 @@ fn build_feature_maps(
|
|||
}
|
||||
}
|
||||
|
||||
// Add travel time aggregation fields
|
||||
for (ti, agg_map) in travel_aggs.iter().enumerate() {
|
||||
if let Some(agg) = agg_map.get(&cell_id) {
|
||||
if agg.count > 0 {
|
||||
let key = &travel_field_keys[ti];
|
||||
let avg = agg.sum / agg.count as f64;
|
||||
if let Some(nm) = serde_json::Number::from_f64(agg.min as f64) {
|
||||
map.insert(format!("min_{key}"), Value::Number(nm));
|
||||
}
|
||||
if let Some(nm) = serde_json::Number::from_f64(agg.max as f64) {
|
||||
map.insert(format!("max_{key}"), Value::Number(nm));
|
||||
}
|
||||
if let Some(nm) = serde_json::Number::from_f64(avg) {
|
||||
map.insert(format!("avg_{key}"), Value::Number(nm));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
features.push(map);
|
||||
}
|
||||
|
||||
|
|
@ -151,12 +181,21 @@ fn build_feature_maps(
|
|||
|
||||
pub async fn get_hexagons(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<HexagonParams>,
|
||||
) -> Result<Json<HexagonsResponse>, (StatusCode, String)> {
|
||||
) -> Result<Json<HexagonsResponse>, axum::response::Response> {
|
||||
let resolution = params.resolution;
|
||||
validate_h3_resolution(resolution)?;
|
||||
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let (south, west, north, east) = require_bounds(params.bounds)?;
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
// Skip license check at low resolutions (≤5) — data is too aggregated to be
|
||||
// commercially useful, and the homepage demo needs country-wide access.
|
||||
if resolution > 5 {
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
}
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
|
|
@ -164,30 +203,49 @@ pub async fn get_hexagons(
|
|||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
|
||||
.map_err(|err| (err.0, err.1).into_response())?;
|
||||
|
||||
// Parse travel entries
|
||||
let travel_entries = params
|
||||
.travel
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|val| !val.is_empty())
|
||||
.map(parse_travel_entries)
|
||||
.transpose()
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Capture what we need for the R5 calls before moving state into spawn_blocking
|
||||
let r5_url = state.r5_url.clone();
|
||||
let http_client = state.http_client.clone();
|
||||
|
||||
let mut response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
// Load travel time data from precomputed parquet files
|
||||
let travel_data: Vec<TravelData> = if !travel_entries.is_empty() {
|
||||
let store = &state.travel_time_store;
|
||||
travel_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
store
|
||||
.get(&entry.mode, &entry.slug)
|
||||
.map_err(|err| format!("Failed to load travel data: {}", err))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let has_travel = !travel_entries.is_empty();
|
||||
let travel_field_keys: Vec<String> = travel_entries
|
||||
.iter()
|
||||
.map(|te| format!("tt_{}_{}", te.mode, te.slug))
|
||||
.collect();
|
||||
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||
let min_keys = &state.min_keys;
|
||||
let max_keys = &state.max_keys;
|
||||
let avg_keys = &state.avg_keys;
|
||||
|
|
@ -198,49 +256,70 @@ pub async fn get_hexagons(
|
|||
let need_parent = needs_parent(resolution);
|
||||
|
||||
let mut groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
|
||||
let mut travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> =
|
||||
(0..travel_entries.len()).map(|_| FxHashMap::default()).collect();
|
||||
|
||||
// Hoist has_selective branch outside the hot loop to avoid per-row branching
|
||||
if let Some(sel_indices) = field_indices.as_deref() {
|
||||
state
|
||||
.grid
|
||||
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||
let row = row_idx as usize;
|
||||
if !row_passes_filters(
|
||||
row,
|
||||
&parsed_filters,
|
||||
&parsed_enum_filters,
|
||||
feature_data,
|
||||
num_features,
|
||||
) {
|
||||
return;
|
||||
// Main aggregation loop
|
||||
let aggregate_row =
|
||||
|row: usize,
|
||||
groups: &mut FxHashMap<u64, Aggregator>,
|
||||
travel_aggs: &mut [FxHashMap<u64, TravelTimeAgg>]| {
|
||||
// Regular filters
|
||||
if !row_passes_filters(
|
||||
row,
|
||||
&parsed_filters,
|
||||
&parsed_enum_filters,
|
||||
feature_data,
|
||||
num_features,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Travel time filter: check each entry with a range
|
||||
let mut travel_minutes: Vec<Option<i16>> = Vec::new();
|
||||
if has_travel {
|
||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||
travel_minutes.reserve(travel_entries.len());
|
||||
for (ti, entry) in travel_entries.iter().enumerate() {
|
||||
let minutes = travel_data[ti].get(postcode).copied();
|
||||
travel_minutes.push(minutes);
|
||||
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
|
||||
match minutes {
|
||||
Some(mins) if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
|
||||
_ => return, // Filtered out
|
||||
}
|
||||
}
|
||||
}
|
||||
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
|
||||
let aggregation = groups
|
||||
.entry(cell_id)
|
||||
.or_insert_with(|| Aggregator::new(num_features));
|
||||
}
|
||||
|
||||
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
|
||||
|
||||
// Aggregate regular features
|
||||
let aggregation = groups
|
||||
.entry(cell_id)
|
||||
.or_insert_with(|| Aggregator::new(num_features));
|
||||
if let Some(sel_indices) = field_indices.as_deref() {
|
||||
aggregation.add_row_selective(feature_data, row, num_features, sel_indices);
|
||||
});
|
||||
} else {
|
||||
state
|
||||
.grid
|
||||
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||
let row = row_idx as usize;
|
||||
if !row_passes_filters(
|
||||
row,
|
||||
&parsed_filters,
|
||||
&parsed_enum_filters,
|
||||
feature_data,
|
||||
num_features,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
|
||||
let aggregation = groups
|
||||
.entry(cell_id)
|
||||
.or_insert_with(|| Aggregator::new(num_features));
|
||||
} else {
|
||||
aggregation.add_row(feature_data, row, num_features);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate travel time
|
||||
for (ti, minutes) in travel_minutes.iter().enumerate() {
|
||||
if let Some(mins) = minutes {
|
||||
let agg = travel_aggs[ti]
|
||||
.entry(cell_id)
|
||||
.or_insert_with(TravelTimeAgg::new);
|
||||
agg.add(*mins as f32);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
state
|
||||
.grid
|
||||
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||
aggregate_row(row_idx as usize, &mut groups, &mut travel_aggs);
|
||||
});
|
||||
|
||||
let t_agg = t0.elapsed();
|
||||
|
||||
|
|
@ -252,6 +331,8 @@ pub async fn get_hexagons(
|
|||
num_features,
|
||||
field_indices.as_deref(),
|
||||
(south, west, north, east),
|
||||
&travel_aggs,
|
||||
&travel_field_keys,
|
||||
);
|
||||
|
||||
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
|
||||
|
|
@ -268,6 +349,7 @@ pub async fn get_hexagons(
|
|||
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
|
||||
filters = num_filters,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
travel_entries = travel_entries.len(),
|
||||
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
|
||||
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||
"GET /api/hexagons"
|
||||
|
|
@ -276,76 +358,8 @@ pub async fn get_hexagons(
|
|||
Ok(HexagonsResponse { features })
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
|
||||
// If travel entries were requested and R5 is configured, fetch travel times concurrently.
|
||||
if !travel_entries.is_empty() {
|
||||
let url = r5_url.as_deref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Travel time queries require routing service (R5_URL not configured)".into(),
|
||||
))?;
|
||||
|
||||
// Collect hex centroids
|
||||
let origins: Vec<[f64; 2]> = response
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let lat = f
|
||||
.get("lat")
|
||||
.and_then(|v| v.as_f64())
|
||||
.expect("lat must be present in feature map");
|
||||
let lon = f
|
||||
.get("lon")
|
||||
.and_then(|v| v.as_f64())
|
||||
.expect("lon must be present in feature map");
|
||||
[lat, lon]
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fire concurrent R5 calls for each travel entry
|
||||
let mut handles = Vec::with_capacity(travel_entries.len());
|
||||
for entry in &travel_entries {
|
||||
let client = http_client.clone();
|
||||
let url = url.to_string();
|
||||
let origins = origins.clone();
|
||||
let dest = [entry.lat, entry.lon];
|
||||
let mode = entry.mode.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
fetch_travel_times(&client, &url, origins, dest, &mode).await
|
||||
}));
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(handles.len());
|
||||
for handle in handles {
|
||||
results.push(handle.await);
|
||||
}
|
||||
for (entry, result) in travel_entries.iter().zip(results) {
|
||||
let travel_times = result
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.map_err(|err| (StatusCode::BAD_GATEWAY, err))?;
|
||||
|
||||
let field_name = format!("travel_time_{}", entry.mode);
|
||||
for (feature, tt) in response.features.iter_mut().zip(&travel_times) {
|
||||
match tt {
|
||||
Some(minutes) => {
|
||||
if let Some(num) = serde_json::Number::from_f64(*minutes) {
|
||||
feature.insert(field_name.clone(), Value::Number(num));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
feature.insert(field_name.clone(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
hexagons = response.features.len(),
|
||||
destination = format_args!("{},{}", entry.lat, entry.lon),
|
||||
mode = entry.mode,
|
||||
"Travel times merged"
|
||||
);
|
||||
}
|
||||
}
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
|
|||
374
server-rs/src/routes/invites.rs
Normal file
374
server-rs/src/routes/invites.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteResponse {
|
||||
code: String,
|
||||
url: String,
|
||||
invite_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteValidation {
|
||||
valid: bool,
|
||||
invite_type: String,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RedeemRequest {
|
||||
code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RedeemResponse {
|
||||
/// "licensed" if admin invite was redeemed directly, or a checkout URL for referral
|
||||
result: String,
|
||||
/// For referral invites: the Stripe checkout URL with coupon
|
||||
checkout_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Validate that an invite code contains only safe characters (alphanumeric, lowercase).
|
||||
/// Rejects any code that could be used for PocketBase filter injection.
|
||||
fn validate_invite_code(code: &str) -> Result<(), &'static str> {
|
||||
if code.is_empty() || code.len() > 20 {
|
||||
return Err("Invalid invite code length");
|
||||
}
|
||||
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
return Err("Invalid invite code characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_invite_code() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let chars: Vec<char> = (0..12)
|
||||
.map(|_| {
|
||||
let idx: u8 = rng.random_range(0..36);
|
||||
if idx < 10 {
|
||||
(b'0' + idx) as char
|
||||
} else {
|
||||
(b'a' + idx - 10) as char
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Create an invite. Admins create "admin" invites (free license).
|
||||
/// Licensed non-admin users create "referral" invites (30% off).
|
||||
pub async fn post_invites(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
_body: Json<serde_json::Value>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
let invite_type = if user.is_admin {
|
||||
"admin"
|
||||
} else if user.subscription == "licensed" {
|
||||
"referral"
|
||||
} else {
|
||||
return (StatusCode::FORBIDDEN, "Only licensed users can create invites").into_response();
|
||||
};
|
||||
|
||||
let code = generate_invite_code();
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let create_url = format!("{pb_url}/api/collections/invites/records");
|
||||
let res = state
|
||||
.http_client
|
||||
.post(&create_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"code": code,
|
||||
"created_by": user.id,
|
||||
"invite_type": invite_type,
|
||||
"used_by_id": "",
|
||||
"used_at": "",
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let public_url = &state.public_url;
|
||||
let url = format!("{public_url}/invite/{code}");
|
||||
info!(code = %code, invite_type, user_id = %user.id, "Created invite");
|
||||
Json(InviteResponse {
|
||||
code,
|
||||
url,
|
||||
invite_type: invite_type.to_string(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Failed to create invite ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an invite code. Requires authentication to prevent enumeration.
|
||||
pub async fn get_invite(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Path(code): Path<String>,
|
||||
) -> Response {
|
||||
if user.0.is_none() {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
if let Err(msg) = validate_invite_code(&code) {
|
||||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("code=\"{}\"", code);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state.http_client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !res.status().is_success() {
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
|
||||
};
|
||||
|
||||
let items = body["items"].as_array();
|
||||
match items.and_then(|arr| arr.first()) {
|
||||
Some(invite) => {
|
||||
let invite_type = invite["invite_type"].as_str().unwrap_or("").to_string();
|
||||
let used_by = invite["used_by_id"].as_str().unwrap_or("");
|
||||
let used = !used_by.is_empty();
|
||||
Json(InviteValidation {
|
||||
valid: true,
|
||||
invite_type,
|
||||
used,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
None => Json(InviteValidation {
|
||||
valid: false,
|
||||
invite_type: String::new(),
|
||||
used: false,
|
||||
})
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Redeem an invite code. Requires authentication.
|
||||
/// Admin invite: sets subscription to "licensed" directly.
|
||||
/// Referral invite: returns a discounted Stripe checkout URL.
|
||||
pub async fn post_redeem_invite(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<RedeemRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
if let Err(msg) = validate_invite_code(&req.code) {
|
||||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Look up invite
|
||||
let filter = format!(
|
||||
"code=\"{}\" && used_by_id=\"\"",
|
||||
req.code
|
||||
);
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state.http_client.get(&lookup_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send().await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
|
||||
};
|
||||
|
||||
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
|
||||
Some(inv) => inv.clone(),
|
||||
None => {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let invite_id = invite["id"].as_str().unwrap_or("");
|
||||
let invite_type = invite["invite_type"].as_str().unwrap_or("");
|
||||
|
||||
// Mark invite as used
|
||||
let now = {
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
dur.as_secs().to_string()
|
||||
};
|
||||
let _ = state
|
||||
.http_client
|
||||
.patch(&format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"used_by_id": user.id,
|
||||
"used_at": now,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if invite_type == "admin" {
|
||||
// Grant license directly
|
||||
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&update_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
|
||||
Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to update user subscription for admin invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Referral invite — create discounted checkout with dynamic pricing
|
||||
let count = match super::pricing::count_licensed_users(&state).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users for invite checkout: {err}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
};
|
||||
let price_pence = super::pricing::price_for_count(count);
|
||||
|
||||
let secret_key = &state.stripe_secret_key;
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()),
|
||||
];
|
||||
|
||||
let stripe_res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match stripe_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let checkout_url = stripe_body["url"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
|
||||
Json(RedeemResponse {
|
||||
result: "checkout".to_string(),
|
||||
checkout_url: Some(checkout_url),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to create Stripe checkout for referral invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
server-rs/src/routes/newsletter.rs
Normal file
64
server-rs/src/routes/newsletter.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNewsletterRequest {
|
||||
newsletter: bool,
|
||||
}
|
||||
|
||||
pub async fn patch_newsletter(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<UpdateNewsletterRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to authenticate as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "newsletter": req.newsletter }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase user update failed ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,9 +23,16 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
|
|||
let method = req.method().clone();
|
||||
let mut builder = state.http_client.request(method, &url);
|
||||
|
||||
// Forward headers except host
|
||||
// Forward only safe headers (allowlist)
|
||||
const ALLOWED_HEADERS: &[&str] = &[
|
||||
"content-type",
|
||||
"accept",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"accept-language",
|
||||
];
|
||||
for (name, value) in req.headers() {
|
||||
if name != "host" {
|
||||
if ALLOWED_HEADERS.contains(&name.as_str()) {
|
||||
builder = builder.header(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ use axum::response::Json;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::data::slugify;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PlaceResult {
|
||||
name: String,
|
||||
slug: String,
|
||||
place_type: String,
|
||||
lat: f32,
|
||||
lon: f32,
|
||||
|
|
@ -28,6 +30,8 @@ pub struct PlacesResponse {
|
|||
pub struct PlacesParams {
|
||||
q: String,
|
||||
limit: Option<usize>,
|
||||
/// If set, only return places that have travel time data for this mode.
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_places(
|
||||
|
|
@ -41,33 +45,44 @@ pub async fn get_places(
|
|||
};
|
||||
|
||||
let limit = params.limit.unwrap_or(7).min(20);
|
||||
let mode_filter = params.mode;
|
||||
|
||||
let places = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let query_lower = query.to_lowercase();
|
||||
let pd = &state.place_data;
|
||||
let tt_store = &state.travel_time_store;
|
||||
|
||||
// Linear scan — ~50-100k rows, <1ms
|
||||
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len)
|
||||
let mut matches: Vec<(usize, bool, bool, u8, u32, usize)> = pd
|
||||
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len, slug)
|
||||
let mut matches: Vec<(usize, bool, bool, u8, u32, usize, String)> = pd
|
||||
.name_lower
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
if name.contains(&query_lower) {
|
||||
let is_exact = name.len() == query_lower.len();
|
||||
let is_prefix = name.starts_with(&query_lower);
|
||||
Some((
|
||||
idx,
|
||||
is_exact,
|
||||
is_prefix,
|
||||
pd.type_rank[idx],
|
||||
pd.population[idx],
|
||||
pd.name[idx].len(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
if !name.contains(&query_lower) {
|
||||
return None;
|
||||
}
|
||||
let slug = slugify(&pd.name[idx]);
|
||||
|
||||
// If mode filter is set, only include places with travel data
|
||||
if let Some(ref mode) = mode_filter {
|
||||
if !tt_store.has_destination(mode, &slug) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let is_exact = name.len() == query_lower.len();
|
||||
let is_prefix = name.starts_with(&query_lower);
|
||||
Some((
|
||||
idx,
|
||||
is_exact,
|
||||
is_prefix,
|
||||
pd.type_rank[idx],
|
||||
pd.population[idx],
|
||||
pd.name[idx].len(),
|
||||
slug,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -85,12 +100,13 @@ pub async fn get_places(
|
|||
|
||||
let results: Vec<PlaceResult> = matches
|
||||
.iter()
|
||||
.map(|&(idx, ..)| PlaceResult {
|
||||
name: pd.name[idx].clone(),
|
||||
place_type: pd.place_type.get(idx).to_string(),
|
||||
lat: pd.lat[idx],
|
||||
lon: pd.lon[idx],
|
||||
city: pd.city[idx].clone(),
|
||||
.map(|(idx, .., slug)| PlaceResult {
|
||||
name: pd.name[*idx].clone(),
|
||||
slug: slug.clone(),
|
||||
place_type: pd.place_type.get(*idx).to_string(),
|
||||
lat: pd.lat[*idx],
|
||||
lon: pd.lon[*idx],
|
||||
city: pd.city[*idx].clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -99,6 +115,7 @@ pub async fn get_places(
|
|||
query = query.as_str(),
|
||||
results = results.len(),
|
||||
scanned = pd.name_lower.len(),
|
||||
mode = mode_filter.as_deref().unwrap_or("-"),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/places"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
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;
|
||||
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
|
||||
use crate::state::AppState;
|
||||
|
||||
|
|
@ -24,8 +27,9 @@ pub struct PostcodeStatsParams {
|
|||
|
||||
pub async fn get_postcode_stats(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<PostcodeStatsParams>,
|
||||
) -> Result<Json<HexagonStatsResponse>, (StatusCode, String)> {
|
||||
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
|
||||
// Normalize postcode: uppercase, collapse whitespace
|
||||
let normalized = params
|
||||
.postcode
|
||||
|
|
@ -42,18 +46,23 @@ pub async fn get_postcode_stats(
|
|||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Postcode not found: {}", normalized),
|
||||
));
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
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)
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
|
@ -129,8 +138,8 @@ pub async fn get_postcode_stats(
|
|||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
|
||||
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::aggregation::Aggregator;
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{
|
||||
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
|
||||
};
|
||||
|
|
@ -60,9 +63,14 @@ fn build_postcode_geometry(rings: &[Vec<[f32; 2]>]) -> Value {
|
|||
|
||||
pub async fn get_postcodes(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<PostcodeParams>,
|
||||
) -> Result<Json<PostcodesResponse>, (StatusCode, String)> {
|
||||
let (south, west, north, east) = require_bounds(params.bounds)?;
|
||||
) -> Result<Json<PostcodesResponse>, axum::response::Response> {
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
|
|
@ -70,10 +78,11 @@ pub async fn get_postcodes(
|
|||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
|
||||
.map_err(|err| (err.0, err.1).into_response())?;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
|
||||
let postcode_data = &state.postcode_data;
|
||||
|
|
@ -222,7 +231,7 @@ pub async fn get_postcodes(
|
|||
}
|
||||
}
|
||||
|
||||
let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
|
||||
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
|
||||
let t_total = t0.elapsed();
|
||||
info!(
|
||||
postcodes_before_filter,
|
||||
|
|
@ -242,8 +251,8 @@ pub async fn get_postcodes(
|
|||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
|
|||
105
server-rs/src/routes/pricing.rs
Normal file
105
server-rs/src/routes/pricing.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Pricing tiers: (cumulative user cap, price in pence).
|
||||
const TIERS: &[(u64, u64)] = &[
|
||||
(10, 0), // First 10 users: free
|
||||
(20, 1000), // Next 10: £10
|
||||
(45, 2500), // Next 25: £25
|
||||
(95, 5000), // Next 50: £50
|
||||
];
|
||||
const FINAL_PRICE_PENCE: u64 = 10000; // £100 after 95
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Tier {
|
||||
up_to: Option<u64>,
|
||||
price_pence: u64,
|
||||
slots: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PricingResponse {
|
||||
licensed_count: u64,
|
||||
current_price_pence: u64,
|
||||
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 {
|
||||
for &(cap, price) in TIERS {
|
||||
if count < cap {
|
||||
return price;
|
||||
}
|
||||
}
|
||||
FINAL_PRICE_PENCE
|
||||
}
|
||||
|
||||
/// Count users with subscription="licensed" in PocketBase.
|
||||
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
|
||||
|
||||
let filter = "subscription=\"licensed\"";
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/users/records?filter={}&perPage=1",
|
||||
urlencoding::encode(filter)
|
||||
);
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("PocketBase returned {}", resp.status());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let total = body["totalItems"].as_u64().unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
pub async fn get_pricing(state: Arc<AppState>) -> Response {
|
||||
let count = match count_licensed_users(&state).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users: {err}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let current_price = price_for_count(count);
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
let mut prev_cap = 0u64;
|
||||
for &(cap, price) in TIERS {
|
||||
tiers.push(Tier {
|
||||
up_to: Some(cap),
|
||||
price_pence: price,
|
||||
slots: cap - prev_cap,
|
||||
});
|
||||
prev_cap = cap;
|
||||
}
|
||||
tiers.push(Tier {
|
||||
up_to: None,
|
||||
price_pence: FINAL_PRICE_PENCE,
|
||||
slots: 0,
|
||||
});
|
||||
|
||||
Json(PricingResponse {
|
||||
licensed_count: count,
|
||||
current_price_pence: current_price,
|
||||
tiers,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -3,12 +3,15 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
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::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{
|
||||
cell_for_row, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
|
||||
validate_h3_resolution,
|
||||
|
|
@ -90,19 +93,25 @@ fn lookup_enum_value(
|
|||
|
||||
pub async fn get_hexagon_properties(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<HexagonPropertiesParams>,
|
||||
) -> Result<Json<HexagonPropertiesResponse>, (StatusCode, String)> {
|
||||
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
|
||||
let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| {
|
||||
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid H3 cell: {}", error),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let cell_u64: u64 = cell.into();
|
||||
|
||||
let resolution = params.resolution;
|
||||
validate_h3_resolution(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);
|
||||
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
|
||||
|
||||
let h3_str = params.h3.clone();
|
||||
let filters_str = params.filters.clone();
|
||||
|
|
@ -111,7 +120,7 @@ pub async fn get_hexagon_properties(
|
|||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
|
|
@ -249,8 +258,8 @@ pub async fn get_hexagon_properties(
|
|||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
|
||||
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rustc_hash::FxHashMap;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::consts::MAX_PRICE_HISTORY_POINTS;
|
||||
use crate::data::FeatureStats;
|
||||
|
|
@ -78,6 +79,13 @@ pub fn compute_feature_stats(
|
|||
let idx = value as usize;
|
||||
if idx < value_counts.len() {
|
||||
value_counts[idx] += 1;
|
||||
} else {
|
||||
warn!(
|
||||
feature = feature_name.as_str(),
|
||||
idx,
|
||||
max = value_counts.len(),
|
||||
"Enum index out of bounds — possible data/schema mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
server-rs/src/routes/stripe_webhook.rs
Normal file
129
server-rs/src/routes/stripe_webhook.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// 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"
|
||||
let mut timestamp = None;
|
||||
let mut signature = None;
|
||||
for part in sig_header.split(',') {
|
||||
if let Some(ts) = part.strip_prefix("t=") {
|
||||
timestamp = Some(ts);
|
||||
} else if let Some(sig) = part.strip_prefix("v1=") {
|
||||
signature = Some(sig);
|
||||
}
|
||||
}
|
||||
|
||||
let (ts, sig_hex) = match (timestamp, signature) {
|
||||
(Some(t), Some(s)) => (t, s),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Compute expected signature: HMAC-SHA256(secret, "TIMESTAMP.PAYLOAD")
|
||||
let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(payload));
|
||||
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(signed_payload.as_bytes());
|
||||
|
||||
// Decode the provided hex signature and verify with constant-time comparison
|
||||
let sig_bytes = match hex::decode(sig_hex) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.verify_slice(&sig_bytes).is_ok()
|
||||
}
|
||||
|
||||
/// Handle Stripe webhook events.
|
||||
/// On `checkout.session.completed`, updates the user's subscription to "licensed".
|
||||
pub async fn post_stripe_webhook(
|
||||
state: Arc<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let webhook_secret = &state.stripe_webhook_secret;
|
||||
|
||||
let sig_header = match headers.get("stripe-signature").and_then(|h| h.to_str().ok()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!("Missing Stripe-Signature header");
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !verify_signature(&body, sig_header, webhook_secret) {
|
||||
warn!("Invalid Stripe webhook signature");
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
let event: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse webhook body: {err}");
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let event_type = event["type"].as_str().unwrap_or("");
|
||||
info!(event_type, "Received Stripe webhook");
|
||||
|
||||
if event_type == "checkout.session.completed" {
|
||||
let user_id = event["data"]["object"]["client_reference_id"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
if user_id.is_empty() {
|
||||
warn!("checkout.session.completed missing client_reference_id");
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
// Update user subscription to "licensed" via PocketBase superuser auth
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser in webhook: {err}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(user_id);
|
||||
info!(user_id, "User subscription updated to licensed via Stripe webhook");
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!(user_id, "Failed to update user subscription ({status}): {text}");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(user_id, "PocketBase request error in webhook: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
78
server-rs/src/routes/subscription.rs
Normal file
78
server-rs/src/routes/subscription.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
const VALID_SUBSCRIPTIONS: &[&str] = &["free", "licensed"];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateSubscriptionRequest {
|
||||
subscription: String,
|
||||
}
|
||||
|
||||
pub async fn patch_subscription(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<UpdateSubscriptionRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
if !user.is_admin {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
if !VALID_SUBSCRIPTIONS.contains(&req.subscription.as_str()) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid subscription: {}", req.subscription),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to authenticate as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": req.subscription }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase user update failed ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use pmtiles::async_reader::AsyncPmTilesReader;
|
||||
use pmtiles::MmapBackend;
|
||||
|
|
@ -40,7 +40,7 @@ pub struct StyleParams {
|
|||
|
||||
pub async fn get_style(
|
||||
State(reader): State<Arc<TileReader>>,
|
||||
headers: HeaderMap,
|
||||
public_url: String,
|
||||
Query(params): Query<StyleParams>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let is_dark = params.theme.as_deref() == Some("dark");
|
||||
|
|
@ -50,7 +50,7 @@ pub async fn get_style(
|
|||
warn!(error = %err, "Failed to get PMTiles metadata");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get PMTiles metadata: {err}"),
|
||||
"Failed to read tile metadata".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ pub async fn get_style(
|
|||
warn!(error = %err, "Failed to parse PMTiles metadata JSON");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse PMTiles metadata: {err}"),
|
||||
"Failed to parse tile metadata".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
|
@ -70,15 +70,8 @@ pub async fn get_style(
|
|||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build absolute tile URL using the request host
|
||||
let host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|hv| hv.to_str().ok())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing Host header".into(),
|
||||
))?;
|
||||
let tile_url = format!("http://{}/api/tiles/{{z}}/{{x}}/{{y}}", host);
|
||||
// Build absolute tile URL using the configured public URL (not the Host header)
|
||||
let tile_url = format!("{}/api/tiles/{{z}}/{{x}}/{{y}}", public_url.trim_end_matches('/'));
|
||||
let style = build_style(is_dark, &layers, &tile_url);
|
||||
|
||||
Ok((
|
||||
|
|
|
|||
38
server-rs/src/routes/travel_modes.rs
Normal file
38
server-rs/src/routes/travel_modes.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TravelModeInfo {
|
||||
mode: String,
|
||||
destinations: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TravelModesResponse {
|
||||
modes: Vec<TravelModeInfo>,
|
||||
}
|
||||
|
||||
pub async fn get_travel_modes(
|
||||
state: Arc<AppState>,
|
||||
) -> Result<Json<TravelModesResponse>, (StatusCode, String)> {
|
||||
let store = &state.travel_time_store;
|
||||
let modes = store
|
||||
.available_modes
|
||||
.iter()
|
||||
.map(|mode| TravelModeInfo {
|
||||
mode: mode.clone(),
|
||||
destinations: store
|
||||
.destinations
|
||||
.get(mode)
|
||||
.map(|slugs| slugs.len())
|
||||
.unwrap_or(0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(TravelModesResponse { modes }))
|
||||
}
|
||||
|
|
@ -1,72 +1,30 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct R5Request {
|
||||
origin: [f64; 2],
|
||||
destinations: Vec<[f64; 2]>,
|
||||
mode: String,
|
||||
/// Per-hex-cell travel time aggregation.
|
||||
pub struct TravelTimeAgg {
|
||||
pub min: f32,
|
||||
pub max: f32,
|
||||
pub sum: f64,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct R5Response {
|
||||
travel_times: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Call the R5 Java service to compute one-to-many travel times.
|
||||
///
|
||||
/// `origins` are hex centroids as `[lat, lon]`.
|
||||
/// `destination` is the user-chosen point as `[lat, lon]`.
|
||||
/// `mode` is one of "car", "bicycle", "walking", "transit".
|
||||
///
|
||||
/// R5 computes from destination to all origins (one-to-many from the user's chosen point).
|
||||
/// Returns a Vec of travel times in minutes (one per origin), with None for unreachable.
|
||||
pub async fn fetch_travel_times(
|
||||
client: &reqwest::Client,
|
||||
r5_url: &str,
|
||||
origins: Vec<[f64; 2]>,
|
||||
destination: [f64; 2],
|
||||
mode: &str,
|
||||
) -> Result<Vec<Option<f64>>, String> {
|
||||
if origins.is_empty() {
|
||||
return Ok(vec![]);
|
||||
impl TravelTimeAgg {
|
||||
pub fn new() -> Self {
|
||||
TravelTimeAgg {
|
||||
min: f32::INFINITY,
|
||||
max: f32::NEG_INFINITY,
|
||||
sum: 0.0,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let body = R5Request {
|
||||
origin: destination,
|
||||
destinations: origins,
|
||||
mode: mode.to_string(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/travel-times", r5_url))
|
||||
.json(&body)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("R5 request failed: {}", e);
|
||||
format!("R5 routing error: {}", e)
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
warn!("R5 returned {}: {}", status, body);
|
||||
return Err(format!("R5 returned {}: {}", status, body));
|
||||
#[inline]
|
||||
pub fn add(&mut self, value: f32) {
|
||||
if value < self.min {
|
||||
self.min = value;
|
||||
}
|
||||
if value > self.max {
|
||||
self.max = value;
|
||||
}
|
||||
self.sum += value as f64;
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
let r5_resp: R5Response = resp.json().await.map_err(|e| {
|
||||
warn!("Failed to parse R5 response: {}", e);
|
||||
format!("Failed to parse R5 response: {}", e)
|
||||
})?;
|
||||
|
||||
// R5 returns -1 for unreachable destinations
|
||||
let travel_times: Vec<Option<f64>> = r5_resp
|
||||
.travel_times
|
||||
.into_iter()
|
||||
.map(|t| if t < 0.0 { None } else { Some(t) })
|
||||
.collect();
|
||||
|
||||
Ok(travel_times)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::auth::TokenCache;
|
||||
use crate::data::{POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData};
|
||||
use crate::data::{POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore};
|
||||
use crate::routes::FeaturesResponse;
|
||||
use crate::utils::GridIndex;
|
||||
|
||||
|
|
@ -34,18 +34,22 @@ pub struct AppState {
|
|||
pub screenshot_url: String,
|
||||
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.schmelczer.dev)
|
||||
pub public_url: String,
|
||||
/// Contents of index.html read at startup, used for crawler OG injection
|
||||
/// Contents of index.html read at startup, used for crawler OG injection (None when --dist omitted)
|
||||
pub index_html: Option<String>,
|
||||
/// Shared HTTP client for proxying to the screenshot service and PocketBase
|
||||
pub http_client: reqwest::Client,
|
||||
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
|
||||
pub pocketbase_url: String,
|
||||
/// PocketBase superuser email (needed for admin-only operations like subscription updates)
|
||||
pub pocketbase_admin_email: String,
|
||||
/// PocketBase superuser password
|
||||
pub pocketbase_admin_password: String,
|
||||
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
|
||||
pub ollama_url: String,
|
||||
/// Ollama model name for area summaries (e.g. gemma3:12b)
|
||||
pub ollama_model: String,
|
||||
/// R5 routing service URL for all travel times (None = disabled)
|
||||
pub r5_url: Option<String>,
|
||||
/// Precomputed travel time data store
|
||||
pub travel_time_store: Arc<TravelTimeStore>,
|
||||
/// Token validation cache (60s TTL)
|
||||
pub token_cache: Arc<TokenCache>,
|
||||
/// JSON schema for Ollama structured output in AI filters
|
||||
|
|
@ -54,4 +58,10 @@ pub struct AppState {
|
|||
pub ai_filters_system_prompt: String,
|
||||
/// Google Maps API key for Street View metadata lookups
|
||||
pub google_maps_api_key: String,
|
||||
/// Stripe secret key for creating checkout sessions
|
||||
pub stripe_secret_key: String,
|
||||
/// Stripe webhook signing secret
|
||||
pub stripe_webhook_secret: String,
|
||||
/// Stripe Coupon ID for referral discounts
|
||||
pub stripe_referral_coupon_id: String,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue