Improve FAQ & video rendering, tighten homepage and CSS

This commit is contained in:
Andras Schmelczer 2026-05-04 22:07:30 +01:00
parent 05a1f316e1
commit c69bb0d614
48 changed files with 4689 additions and 1077 deletions

View file

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