All changes

This commit is contained in:
Andras Schmelczer 2026-03-14 21:36:00 +00:00
parent 593f380581
commit 49f7ec2f5a
60 changed files with 1783 additions and 679 deletions

View file

@ -129,17 +129,16 @@ pub async fn get_export(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, (south, west, north, east))?;
let filters_str = params.filters.clone();
let fields_str = params.fields.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let filters_str = params.filters;
let fields_str = params.fields;
let public_url = state.public_url.clone();

View file

@ -97,10 +97,9 @@ pub async fn get_hexagon_stats(
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, h3_bounds)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
let h3_str = params.h3;
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -108,6 +107,7 @@ pub async fn get_hexagon_stats(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());

View file

@ -18,7 +18,7 @@ use crate::parsing::{
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
};
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
use crate::state::AppState;
#[derive(Serialize)]
@ -141,11 +141,9 @@ pub async fn get_hexagons(
let is_demo_view = (south, west, north, east) == DEMO_BOUNDS;
if !is_demo_view {
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, (south, west, north, east))?;
}
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -153,19 +151,13 @@ pub async fn get_hexagons(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
.map_err(|err| (err.0, err.1).into_response())?;
// Parse travel entries
let travel_entries = params
.travel
.as_deref()
.filter(|val| !val.is_empty())
.map(parse_travel_entries)
.transpose()
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
.unwrap_or_default();
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let t0 = std::time::Instant::now();

View file

@ -18,11 +18,26 @@ struct InviteResponse {
invite_type: String,
}
#[derive(Serialize)]
struct InviteListItem {
code: String,
url: String,
invite_type: String,
used: bool,
created: String,
}
#[derive(Serialize)]
struct InviteListResponse {
invites: Vec<InviteListItem>,
}
#[derive(Serialize)]
struct InviteValidation {
valid: bool,
invite_type: String,
used: bool,
invited_by: Option<String>,
}
#[derive(Deserialize)]
@ -147,6 +162,10 @@ pub async fn post_invites(
}
}
/// Dev-only fake invite code (12 alphanumeric chars, passes validation).
/// Only recognized when `--dist` is not set (i.e., dev mode).
const DEV_INVITE_CODE: &str = "devdevdevdev";
/// Validate an invite code. Public endpoint — codes are 12-char random alphanumeric
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
pub async fn get_invite(
@ -158,6 +177,17 @@ pub async fn get_invite(
return (StatusCode::BAD_REQUEST, msg).into_response();
}
// Dev-only: return a fake valid admin invite without hitting PocketBase
if state.index_html.is_none() && code == DEV_INVITE_CODE {
return Json(InviteValidation {
valid: true,
invite_type: "admin".to_string(),
used: false,
invited_by: Some("Developer".to_string()),
})
.into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
@ -201,10 +231,38 @@ pub async fn get_invite(
let invite_type = invite["invite_type"].as_str().unwrap_or("").to_string();
let used_by = invite["used_by_id"].as_str().unwrap_or("");
let used = !used_by.is_empty();
let created_by = invite["created_by"].as_str().unwrap_or("");
// Look up inviter's name (email local part)
let invited_by = if !created_by.is_empty() {
let user_url =
format!("{pb_url}/api/collections/users/records/{created_by}");
match state
.http_client
.get(&user_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let user_body: serde_json::Value =
resp.json().await.unwrap_or_default();
user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.map(String::from)
}
_ => None,
}
} else {
None
};
Json(InviteValidation {
valid: true,
invite_type,
used,
invited_by,
})
.into_response()
}
@ -212,6 +270,7 @@ pub async fn get_invite(
valid: false,
invite_type: String::new(),
used: false,
invited_by: None,
})
.into_response(),
}
@ -234,6 +293,16 @@ pub async fn post_redeem_invite(
return (StatusCode::BAD_REQUEST, msg).into_response();
}
// Dev-only: fake redeem — just return "licensed" without touching PocketBase
if state.index_html.is_none() && req.code == DEV_INVITE_CODE {
info!(user_id = %user.id, "Dev invite redeemed (no-op)");
return Json(RedeemResponse {
result: "licensed".to_string(),
checkout_url: None,
})
.into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
@ -290,7 +359,7 @@ pub async fn post_redeem_invite(
};
let _ = state
.http_client
.patch(&format!(
.patch(format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
@ -391,3 +460,97 @@ pub async fn post_redeem_invite(
}
}
}
/// List invites. Admins see all invites; licensed users see only their own.
pub async fn get_invites(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let filter = if user.is_admin {
String::new()
} else {
format!("created_by=\"{}\"", user.id)
};
let mut url = format!(
"{pb_url}/api/collections/invites/records?sort=-created&perPage=200"
);
if !filter.is_empty() {
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
}
let res = match state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to list invites: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
warn!("PocketBase list invites failed ({status}): {text}");
return StatusCode::BAD_GATEWAY.into_response();
}
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse invites response: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let public_url = &state.public_url;
let invites: Vec<InviteListItem> = body["items"]
.as_array()
.map(|arr| {
arr.iter()
.map(|inv| {
let code = inv["code"].as_str().unwrap_or("").to_string();
let invite_type = inv["invite_type"].as_str().unwrap_or("").to_string();
let used_by = inv["used_by_id"].as_str().unwrap_or("");
let created = inv["created"].as_str().unwrap_or("").to_string();
InviteListItem {
url: format!("{public_url}/invite/{code}"),
code,
invite_type,
used: !used_by.is_empty(),
created,
}
})
.collect()
})
.unwrap_or_default();
Json(InviteListResponse { invites }).into_response()
}

View file

@ -41,7 +41,6 @@ pub async fn get_pois(
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
let (south, west, north, east) = require_bounds(params.bounds)?;
let categories_str = params.categories.clone();
let category_filter: Option<rustc_hash::FxHashSet<String>> = params
.categories
.as_deref()
@ -51,6 +50,7 @@ pub async fn get_pois(
.map(|part| part.trim().to_string())
.collect()
});
let categories_raw = params.categories;
let num_categories = category_filter.as_ref().map(|cats| cats.len()).unwrap_or(0);
@ -100,7 +100,7 @@ pub async fn get_pois(
results = pois.len(),
candidates = row_indices.len(),
categories = num_categories,
categories_raw = categories_str.as_deref().unwrap_or("-"),
categories_raw = categories_raw.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/pois"
);

View file

@ -44,10 +44,8 @@ pub async fn get_postcode_properties(
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
.map_err(|(_, resp)| resp)?;
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -55,8 +53,9 @@ pub async fn get_postcode_properties(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let postcode_str = normalized.clone();
let postcode_str = normalized;
let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();

View file

@ -48,10 +48,8 @@ pub async fn get_postcode_stats(
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
// License check using postcode centroid
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
.map_err(|(_, resp)| resp)?;
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -59,10 +57,11 @@ pub async fn get_postcode_stats(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
let postcode_str = normalized.clone();
let postcode_str = normalized;
let response = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();

View file

@ -17,7 +17,7 @@ use crate::licensing::check_license_bounds;
use crate::parsing::{
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
};
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
use crate::state::AppState;
use crate::utils::normalize_postcode;
@ -74,10 +74,8 @@ pub async fn get_postcodes(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, (south, west, north, east))?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -85,19 +83,13 @@ pub async fn get_postcodes(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
.map_err(|err| (err.0, err.1).into_response())?;
// Parse travel entries
let travel_entries = params
.travel
.as_deref()
.filter(|val| !val.is_empty())
.map(parse_travel_entries)
.transpose()
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
.unwrap_or_default();
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
let postcode_data = &state.postcode_data;

View file

@ -172,10 +172,9 @@ pub async fn get_hexagon_properties(
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, h3_bounds)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
let h3_str = params.h3;
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -183,6 +182,7 @@ pub async fn get_hexagon_properties(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();

View file

@ -1,3 +1,11 @@
/// Parse the optional `travel` query param, returning an empty Vec when absent or empty.
pub fn parse_optional_travel(travel: Option<&str>) -> Result<Vec<TravelEntry>, String> {
match travel.filter(|val| !val.is_empty()) {
Some(s) => parse_travel_entries(s),
None => Ok(Vec::new()),
}
}
/// A parsed travel time entry from the `travel` query parameter.
pub struct TravelEntry {
pub mode: String,
@ -9,7 +17,7 @@ pub struct TravelEntry {
/// Parse `travel` param into a list of travel entries.
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
pub fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
let mut entries = Vec::new();
let mut seen_keys = Vec::new();
for segment in travel_str.split('|') {