Improve FAQ & video rendering, tighten homepage and CSS
This commit is contained in:
parent
05a1f316e1
commit
c69bb0d614
48 changed files with 4689 additions and 1077 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue