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