perfect-postcode/server-rs/src/licensing.rs

226 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}