use std::time::Instant; use axum::http::StatusCode; use axum::response::IntoResponse; use parking_lot::RwLock; use rustc_hash::FxHashMap; use serde_json::{json, Value}; use tracing::warn; use crate::auth::PocketBaseUser; use crate::consts::FREE_ZONE_BOUNDS; use crate::pocketbase::get_superuser_token; use crate::state::AppState; const SHARE_CACHE_TTL_SECS: u64 = 300; const SHARE_CACHE_MAX_ENTRIES: usize = 1024; #[derive(Clone, Copy, Debug)] pub struct ShareBounds { pub south: f64, pub west: f64, pub north: f64, pub east: f64, } /// Cache: code → resolved share bounds. We cache `None` too so an invalid /// code doesn't keep hammering PocketBase on every request from a malicious /// or stale client. pub struct ShareBoundsCache { entries: RwLock, Instant)>>, } impl ShareBoundsCache { pub fn new() -> Self { Self { entries: RwLock::new(FxHashMap::default()), } } fn get(&self, code: &str) -> Option> { let map = self.entries.read(); if let Some((bounds, created)) = map.get(code) { if created.elapsed().as_secs() < SHARE_CACHE_TTL_SECS { return Some(*bounds); } } None } fn insert(&self, code: String, bounds: Option) { let mut map = self.entries.write(); if map.len() >= SHARE_CACHE_MAX_ENTRIES { let now = Instant::now(); map.retain(|_, (_, created)| { now.duration_since(*created).as_secs() < SHARE_CACHE_TTL_SECS }); if map.len() >= SHARE_CACHE_MAX_ENTRIES { let mut ages: Vec = map.values().map(|(_, c)| *c).collect(); ages.sort(); let median = ages[ages.len() / 2]; map.retain(|_, (_, created)| *created >= median); } } map.insert(code, (bounds, Instant::now())); } } impl Default for ShareBoundsCache { fn default() -> Self { Self::new() } } /// Resolve a share code to the bbox the share grants access to. /// Looks up the stored params for the code in PocketBase, parses lat/lon/zoom, /// and derives a generous bbox sized to roughly 4× the viewport at that zoom. /// Returns `None` if the code is invalid or unknown. pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option { if !is_valid_share_code(code) { return None; } if let Some(cached) = state.share_cache.get(code) { return cached; } let resolved = fetch_share_bounds(state, code).await; state.share_cache.insert(code.to_string(), resolved); resolved } /// Convenience: resolve `Option<&str>` share code → `Option`. /// Skips the lookup entirely (and never touches the cache) when no code is /// supplied or the supplied code is empty. pub async fn resolve_share_code(state: &AppState, code: Option<&str>) -> Option { match code { Some(c) if !c.is_empty() => lookup_share_bounds(state, c).await, _ => None, } } fn is_valid_share_code(code: &str) -> bool { !code.is_empty() && code.len() <= 20 && code.bytes().all(|b| b.is_ascii_alphanumeric()) } async fn fetch_share_bounds(state: &AppState, code: &str) -> Option { let token = match get_superuser_token(state).await { Ok(t) => t, Err(err) => { warn!("share bounds lookup: superuser auth failed: {err}"); return None; } }; let pb_url = state.pocketbase_url.trim_end_matches('/'); let filter = format!("code=\"{code}\""); let url = format!( "{pb_url}/api/collections/short_urls/records?filter={}&perPage=1", urlencoding::encode(&filter) ); let resp = state .http_client .get(&url) .header("Authorization", format!("Bearer {token}")) .send() .await .ok()?; if !resp.status().is_success() { return None; } let json: Value = resp.json().await.ok()?; let params = json["items"].as_array()?.first()?.get("params")?.as_str()?; parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom)) } /// Pull `lat`, `lon`, `zoom` out of an already-encoded query string like /// `lat=51.5&lon=-0.1&zoom=12&filter=...`. Returns `None` if any of the three /// is missing or unparseable — those are the only fields we need for sizing. fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> { let mut lat: Option = None; let mut lon: Option = None; let mut zoom: Option = None; for pair in params.split('&') { let mut it = pair.splitn(2, '='); let key = it.next()?; let val = it.next().unwrap_or(""); match key { "lat" => lat = val.parse().ok(), "lon" => lon = val.parse().ok(), "zoom" => zoom = val.parse().ok(), _ => {} } } Some((lat?, lon?, zoom?)) } /// Derive the share bbox from the share's center lat/lon and zoom. /// /// A viewport W pixels wide at zoom z covers `W * 360 / (256 * 2^z)` degrees /// of longitude. For a typical 1280px-wide desktop viewport that's roughly /// `1800 / 2^z` degrees — we use that as the half-width, so the bbox is /// ~2 viewports per side (~4 viewports total area). Lat is scaled by 0.6 /// to roughly match the latitude compression at UK latitudes. fn bounds_from_view(lat: f64, lon: f64, zoom: f64) -> ShareBounds { let zoom = zoom.clamp(0.0, 20.0); let half_lon = (1800.0 / 2.0_f64.powf(zoom)).min(180.0); let half_lat = (half_lon * 0.6).min(85.0); ShareBounds { south: lat - half_lat, north: lat + half_lat, west: lon - half_lon, east: lon + half_lon, } } /// Check whether the user is allowed to query data at the given bounds. /// Licensed users and admins bypass the check entirely. /// Free/anonymous users get 403 unless the bounds fall inside the free zone /// or inside the bbox granted by a valid share code. #[allow(clippy::result_large_err)] pub fn check_license_bounds( user: &Option, bounds: (f64, f64, f64, f64), share_bounds: Option, ) -> Result<(), axum::response::Response> { 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; if south >= fz_south && west >= fz_west && north <= fz_north && east <= fz_east { return Ok(()); } if let Some(sb) = share_bounds { if south >= sb.south && west >= sb.west && north <= sb.north && east <= sb.east { return Ok(()); } } let body = json!({ "error": "license_required", "message": "A license is required to view data outside the demo area", "free_zone": { "south": fz_south, "west": fz_west, "north": fz_north, "east": fz_east, } }); Err((StatusCode::FORBIDDEN, axum::Json(body)).into_response()) } /// Convenience wrapper that takes a point (lat, lon) instead of bounds. #[allow(clippy::result_large_err)] pub fn check_license_point( user: &Option, lat: f64, lon: f64, share_bounds: Option, ) -> Result<(), axum::response::Response> { check_license_bounds(user, (lat, lon, lat, lon), share_bounds) }