More fixes

This commit is contained in:
Andras Schmelczer 2026-03-18 22:46:08 +00:00
parent 15fa09430b
commit 6b12e21d50
54 changed files with 1665 additions and 630 deletions

View file

@ -23,7 +23,7 @@ pub struct POIData {
/// Byte offset into `id_buffer` where each row's ID starts.
id_offsets: Vec<u32>,
/// Length in bytes of each row's ID.
id_lengths: Vec<u8>,
id_lengths: Vec<u16>,
pub group: InternedColumn,
pub category: InternedColumn,
pub name: Vec<String>,
@ -101,7 +101,7 @@ impl POIData {
let mut id_lengths = Vec::with_capacity(row_count);
for s in &id_raw {
let offset = id_buffer.len() as u32;
let length = s.len().min(u8::MAX as usize) as u8;
let length = s.len().min(u16::MAX as usize) as u16;
id_offsets.push(offset);
id_lengths.push(length);
id_buffer.push_str(&s[..length as usize]);

View file

@ -128,6 +128,7 @@ impl PostcodeData {
// Compute centroid across all vertices from all rings
let total_vertices: usize = rings.iter().map(|ring| ring.len()).sum();
let centroid = if total_vertices == 0 {
tracing::warn!(postcode = %postcode, "Postcode polygon has zero vertices, defaulting centroid to (0,0)");
(0.0, 0.0)
} else {
let mut sum_lat: f32 = 0.0;

View file

@ -68,9 +68,9 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
features: &[
FeatureConfig {
name: "Last known price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_000_000.0,
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 10000.0,
description: "Most recent sale price from the Land Registry",
@ -79,15 +79,15 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "",
raw: false,
absolute: true,
absolute: false,
modes: &["historical"],
linked: "",
},
FeatureConfig {
name: "Estimated current price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_000_000.0,
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 10000.0,
description: "Inflation-adjusted estimate of the current property value",
@ -96,7 +96,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "",
raw: false,
absolute: true,
absolute: false,
modes: &["historical"],
linked: "Asking price",
},
@ -252,9 +252,9 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
FeatureConfig {
name: "Asking price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_000_000.0,
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 10000.0,
description: "Listed asking price for properties currently for sale",
@ -263,15 +263,15 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "",
raw: false,
absolute: true,
absolute: false,
modes: &["buy"],
linked: "Estimated current price",
},
FeatureConfig {
name: "Asking rent (monthly)",
bounds: Bounds::Fixed {
min: 0.0,
max: 10_000.0,
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 50.0,
description: "Listed monthly rent for properties currently for rent",
@ -280,7 +280,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "/mo",
raw: false,
absolute: true,
absolute: false,
modes: &["rent"],
linked: "Estimated monthly rent",
},
@ -870,7 +870,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
step: 1.0,
step: 0.1,
description: "Percentage of population identifying as South Asian",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Indian, Pakistani, Bangladeshi, or any other Asian background.",
source: "ethnicity",
@ -887,7 +887,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
step: 1.0,
step: 0.1,
description: "Percentage of population identifying as East Asian",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.",
source: "ethnicity",
@ -904,7 +904,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
step: 1.0,
step: 0.1,
description: "Percentage of population identifying as Black",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Black, Black British, Caribbean, or African.",
source: "ethnicity",
@ -921,7 +921,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
step: 1.0,
step: 0.1,
description: "Percentage of population identifying as Mixed or Multiple ethnic groups",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Mixed or Multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian, or any other Mixed or Multiple background).",
source: "ethnicity",
@ -938,7 +938,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
step: 1.0,
step: 0.1,
description: "Percentage of population identifying as Other ethnic group",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Other ethnic group (Arab or any other ethnic group not covered by the main categories).",
source: "ethnicity",

View file

@ -365,6 +365,7 @@ async fn main() -> anyhow::Result<()> {
info!("Precomputed AI filters system prompt");
let token_cache = Arc::new(auth::TokenCache::new());
let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
let app_state = AppState {
data: property_data,
@ -392,6 +393,7 @@ async fn main() -> anyhow::Result<()> {
gemini_model: cli.gemini_model,
travel_time_store,
token_cache,
superuser_token_cache,
ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key,
stripe_secret_key: cli.stripe_secret_key,

View file

@ -65,6 +65,14 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
};
let og_url = if query_string.is_empty() {
format!("{}{}", state.public_url, path)
} else {
format!("{}{}?{}", state.public_url, path, query_string)
};
let og_logo = format!("{}/favicon.svg", state.public_url);
let (og_title, og_description) = if is_invite {
(
"You\u{2019}re invited to Perfect Postcode",
@ -81,6 +89,8 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
r#"<meta property="og:title" content="{og_title}" />
<meta property="og:description" content="{og_description}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{og_url}" />
<meta property="og:logo" content="{og_logo}" />
<meta property="og:image" content="{og_image_url}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

View file

@ -54,16 +54,21 @@ pub fn parse_filters(
// Check if this is an enum feature
if let Some(values) = enum_values.get(&feat_idx) {
// Enum filter: convert string values to u16 indices
let allowed: FxHashSet<u16> = rest
.split('|')
.filter_map(|value| {
let value = value.trim();
values
.iter()
.position(|existing| existing == value)
.map(|position| position as u16)
})
.collect();
let mut allowed: FxHashSet<u16> = FxHashSet::default();
for value in rest.split('|') {
let value = value.trim();
match values.iter().position(|existing| existing == value) {
Some(position) => {
allowed.insert(position as u16);
}
None => {
return Err(format!(
"Unknown value '{}' for enum feature '{}'. Valid values: {:?}",
value, name, values
));
}
}
}
enums.push(ParsedEnumFilter { feat_idx, allowed });
} else {
// Numeric filter: parse min:max and encode to u16
@ -369,20 +374,16 @@ mod tests {
}
#[test]
fn parse_enum_with_unknown_value() {
fn parse_enum_with_unknown_value_errors() {
let tq = test_quant(4, 2);
let (_numeric, enums) = parse_filters(
let result = parse_filters(
Some("Type:Detached|Unknown|Flats/Maisonettes"),
&extended_feature_map(),
&extended_enum_values(),
&tq.as_ref(),
)
.unwrap();
assert_eq!(enums.len(), 1);
assert!(enums[0].allowed.contains(&0)); // Detached
assert!(enums[0].allowed.contains(&3)); // Flats/Maisonettes
assert_eq!(enums[0].allowed.len(), 2);
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown value 'Unknown'"));
}
#[test]

View file

@ -1,13 +1,62 @@
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use metrics::gauge;
use parking_lot::RwLock;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::state::AppState;
/// Cache TTL for the superuser token. PocketBase superuser JWTs are valid for
/// ~14 days by default, so 10 minutes is very conservative while eliminating
/// nearly all redundant auth requests (metrics poller, newsletter, invites, etc.).
const SUPERUSER_TOKEN_TTL_SECS: u64 = 600;
pub struct SuperuserTokenCache {
token: RwLock<Option<(String, Instant)>>,
}
impl SuperuserTokenCache {
pub fn new() -> Self {
Self {
token: RwLock::new(None),
}
}
}
/// Get a cached superuser token, or authenticate fresh if expired/missing.
pub async fn get_superuser_token(state: &AppState) -> anyhow::Result<String> {
// Check cache first (read lock — cheap, non-blocking for other readers)
{
let cached = state.superuser_token_cache.token.read();
if let Some((token, created)) = cached.as_ref() {
if created.elapsed().as_secs() < SUPERUSER_TOKEN_TTL_SECS {
return Ok(token.clone());
}
}
}
// Cache miss or expired — fetch a fresh token
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await?;
// Store in cache
{
let mut cached = state.superuser_token_cache.token.write();
*cached = Some((token.clone(), Instant::now()));
}
Ok(token)
}
#[derive(Deserialize)]
struct AuthResponse {
token: String,
@ -775,21 +824,14 @@ pub fn start_metrics_poller(shared: Arc<crate::state::SharedState>) {
}
async fn poll_pocketbase_counts(state: &AppState) {
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
{
let token = match get_superuser_token(state).await {
Ok(tk) => tk,
Err(err) => {
warn!("PocketBase metrics poll auth failed: {err}");
return;
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
// Simple collection counts
for (collection, metric_name) in [

View file

@ -12,7 +12,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
use crate::data::slugify;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::routes::{FeatureInfo, FeaturesResponse};
use crate::state::{AppState, SharedState};
use crate::utils::gemini_chat;
@ -37,6 +37,8 @@ pub struct AiFiltersRequest {
query: String,
/// Current filters for conversational refinement (e.g. "make it cheaper")
context: Option<AiFiltersContext>,
/// Current listing mode (historical/buy/rent). Defaults to "historical".
listing_type: Option<String>,
}
#[derive(Serialize)]
@ -58,6 +60,8 @@ pub struct AiFiltersResponse {
/// What the LLM couldn't map to existing filters (empty if everything matched)
#[serde(skip_serializing_if = "String::is_empty")]
notes: String,
/// The listing mode used for this response (historical/buy/rent)
listing_type: String,
}
/// Strip markdown code fences (```json ... ``` or ``` ... ```) from LLM output.
@ -268,6 +272,37 @@ pub fn build_system_prompt(
modes_list,
));
// Listing modes section
parts.push(
"\n--- LISTING MODES ---\n\
There are three listing modes that control which property data is shown:\n\
- \"historical\": Historical sales from Land Registry (default). Uses features like \
\"Last known price\", \"Estimated current price\", \"Price per sqm\".\n\
- \"buy\": Properties currently listed for sale. Uses features like \"Asking price\", \
\"Asking price per sqm\".\n\
- \"rent\": Properties currently listed for rent. Uses features like \
\"Asking rent (monthly)\".\n\
\n\
When the user mentions buying, purchasing, for-sale properties, or asking prices, \
set listing_type to \"buy\".\n\
When the user mentions renting, letting, rental properties, or monthly rent, \
set listing_type to \"rent\".\n\
When the user doesn't specify or mentions historical prices/past sales, \
omit listing_type to keep the current mode.\n\
\n\
Features marked with [mode] below are only available in that mode. \
Features without a mode annotation work in all modes. \
ONLY use features valid for the chosen listing_type.\n\
If the user mentions price and the mode is \"buy\", use \"Asking price\" (not \"Last known price\").\n\
If the user mentions rent/price and the mode is \"rent\", use \"Asking rent (monthly)\".\n\
\n\
Feature equivalences across modes:\n\
- \"Estimated current price\" (historical) ↔ \"Asking price\" (buy)\n\
- \"Est. price per sqm\" (historical) ↔ \"Asking price per sqm\" (buy)\n\
- \"Estimated monthly rent\" (historical) ↔ \"Asking rent (monthly)\" (rent)"
.to_string(),
);
// Feature catalogue
parts.push("\n--- AVAILABLE FEATURES ---\n".to_string());
for group in &features.groups {
@ -285,11 +320,17 @@ pub fn build_system_prompt(
description,
prefix,
suffix,
modes,
..
} => {
let mode_str = if modes.is_empty() {
String::new()
} else {
format!(" [{}]", modes.join("/"))
};
parts.push(format!(
"- \"{}\" (numeric, {}{:.0}{} to {}{:.0}{}): {}",
name, prefix, min, suffix, prefix, max, suffix, description
"- \"{}\"{} (numeric, {}{:.0}{} to {}{:.0}{}): {}",
name, mode_str, prefix, min, suffix, prefix, max, suffix, description
));
}
FeatureInfo::Enum {
@ -298,6 +339,10 @@ pub fn build_system_prompt(
description,
..
} => {
// Skip Listing status — handled via listing_type field
if name == "Listing status" {
continue;
}
parts.push(format!(
"- \"{}\" (enum, values: [{}]): {}",
name,
@ -381,10 +426,37 @@ pub fn build_system_prompt(
.to_string(),
);
// Examples showing listing mode switching
parts.push(
"\nUser: \"2 bed flat to rent under £1500/month\"\n\
Output: {\"listing_type\": \"rent\", \
\"numeric_filters\": [{\"name\": \"Asking rent (monthly)\", \"bound\": \"max\", \"value\": 1500}], \
\"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flats/Maisonettes\"]}], \
\"travel_time_filters\": [], \
\"notes\": \"\"}"
.to_string(),
);
parts.push(
"\nUser: \"3 bed house to buy under 500k with good schools\"\n\
Output: {\"listing_type\": \"buy\", \
\"numeric_filters\": [{\"name\": \"Asking price\", \"bound\": \"max\", \"value\": 500000}, \
{\"name\": \"Good+ primary schools within 5km\", \"bound\": \"min\", \"value\": 5}], \
\"enum_filters\": [{\"name\": \"Property type\", \
\"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
\"travel_time_filters\": [], \
\"notes\": \"\"}"
.to_string(),
);
// Output format reminder
parts.push(
"\n--- OUTPUT FORMAT ---\n\
{\"numeric_filters\": [...], \"enum_filters\": [...], \"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
{\"listing_type\": \"buy\"|\"rent\" (OPTIONAL — only when switching mode), \
\"numeric_filters\": [...], \"enum_filters\": [...], \
\"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \
\"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
- listing_type: include only when the user explicitly wants to buy or rent. Omit to keep current mode.\n\
- travel_time_filters: use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\
Respond with ONLY the JSON object. No explanation."
.to_string(),
@ -409,19 +481,12 @@ async fn fetch_ai_usage(
state: &AppState,
user_id: &str,
) -> Result<(u64, u64), (StatusCode, String)> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
.map_err(|err| {
let token = get_superuser_token(state).await.map_err(|err| {
warn!("Failed to auth superuser for AI usage check: {err}");
(StatusCode::BAD_GATEWAY, "Internal error".into())
})?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client
@ -460,15 +525,7 @@ async fn fetch_ai_usage(
/// Update the user's AI token usage in PocketBase.
/// Best-effort — logs warnings on failure but does not propagate errors.
async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week: u64) {
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
{
let token = match get_superuser_token(state).await {
Ok(tk) => tk,
Err(err) => {
warn!("Failed to auth superuser for AI usage update: {err}");
@ -476,6 +533,7 @@ async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let res = state
.http_client
@ -533,9 +591,17 @@ pub async fn post_ai_filters(
let tools = build_tool_declarations(&state);
// Build user message with optional context for conversational refinement
// Resolve current listing mode from request
let current_mode = req.listing_type.as_deref().unwrap_or("historical");
let current_mode = match current_mode {
"historical" | "buy" | "rent" => current_mode,
_ => "historical",
};
// Build user message with listing mode and optional context for conversational refinement
let user_text = if let Some(ref ctx) = req.context {
let mut msg = String::new();
msg.push_str(&format!("Current listing mode: {}\n", current_mode));
msg.push_str("Currently active filters:\n");
msg.push_str(&serde_json::to_string(&ctx.filters).unwrap_or_default());
if !ctx.travel_time.is_empty() {
@ -553,7 +619,10 @@ pub async fn post_ai_filters(
msg.push_str(&format!("\nUser request: {}", req.query));
msg
} else {
req.query.clone()
format!(
"Current listing mode: {}\nUser request: {}",
current_mode, req.query
)
};
let mut contents = vec![json!({
@ -679,7 +748,17 @@ pub async fn post_ai_filters(
}
};
let filters = validate_and_convert(&raw, &state.features_response);
// Resolve listing_type: LLM output > request > "historical"
let listing_type = raw
.get("listing_type")
.and_then(|val| val.as_str())
.unwrap_or(current_mode);
let listing_type = match listing_type {
"historical" | "buy" | "rent" => listing_type,
_ => current_mode,
};
let mut filters = validate_and_convert(&raw, &state.features_response, listing_type);
let travel_time_filters = validate_travel_time_filters(&raw, &state);
let notes = raw
.get("notes")
@ -687,6 +766,16 @@ pub async fn post_ai_filters(
.unwrap_or("")
.to_string();
// Auto-inject Listing status filter for the chosen mode
let listing_value = match listing_type {
"buy" => "For sale",
"rent" => "For rent",
_ => "Historical sale",
};
if let Value::Object(ref mut map) = filters {
map.insert("Listing status".to_string(), json!([listing_value]));
}
// Update usage with total accumulated tokens
let new_total = tokens_used + total_tokens_accumulated;
update_ai_usage(&state, &user.id, new_total, current_week).await;
@ -698,6 +787,7 @@ pub async fn post_ai_filters(
filters,
travel_time_filters,
notes,
listing_type: listing_type.to_string(),
}));
}
@ -787,10 +877,10 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTime
/// ```json
/// { "Last known price": [0, 300000], "Leasehold/Freehold": ["Freehold"] }
/// ```
fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type: &str) -> Value {
let mut result = serde_json::Map::new();
// Build lookup maps from feature metadata
// Build lookup maps from feature metadata, filtering by listing mode
let mut numeric_features: rustc_hash::FxHashMap<&str, (f32, f32)> =
rustc_hash::FxHashMap::default();
let mut enum_features: rustc_hash::FxHashMap<&str, &[String]> =
@ -799,11 +889,23 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
for group in &features.groups {
for feature in &group.features {
match feature {
FeatureInfo::Numeric { name, min, max, .. } => {
numeric_features.insert(name, (*min, *max));
FeatureInfo::Numeric {
name,
min,
max,
modes,
..
} => {
// Only include features valid for the chosen listing mode
if modes.is_empty() || modes.contains(&listing_type) {
numeric_features.insert(name, (*min, *max));
}
}
FeatureInfo::Enum { name, values, .. } => {
enum_features.insert(name, values);
// Skip Listing status — handled via auto-injection
if name != "Listing status" {
enum_features.insert(name, values);
}
}
}
}

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::{AppState, SharedState};
use super::pricing::{count_licensed_users, price_for_count};
@ -88,6 +88,8 @@ pub async fn post_checkout(
state.stripe_referral_coupon_id.clone(),
));
info!(code = %code, "Applying referral coupon to checkout");
} else {
warn!(code = %code, "Referral code validation failed, proceeding without discount");
}
}
@ -131,15 +133,9 @@ pub async fn post_checkout(
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await?;
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
#[derive(Serialize)]
@ -118,14 +118,7 @@ pub async fn post_invites(
let code = generate_invite_code();
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@ -202,14 +195,7 @@ pub async fn get_invite(
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@ -325,14 +311,7 @@ pub async fn post_redeem_invite(
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@ -500,14 +479,7 @@ pub async fn get_invites(
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");

View file

@ -8,7 +8,7 @@ use serde::Deserialize;
use tracing::warn;
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
#[derive(Deserialize)]
@ -27,16 +27,7 @@ pub async fn patch_newsletter(
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to authenticate as PocketBase superuser: {err}");
@ -44,6 +35,7 @@ pub async fn patch_newsletter(
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client

View file

@ -281,7 +281,7 @@ pub async fn get_postcodes(
histogram!("postcodes_response_count").record(features.len() as f64);
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
let t_total = t0.elapsed();
info!(
postcodes_before_filter,

View file

@ -7,7 +7,7 @@ use axum::Json;
use serde::Serialize;
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::{AppState, SharedState};
/// Pricing tiers: (cumulative user cap, price in pence).
@ -45,15 +45,9 @@ pub fn price_for_count(count: u64) -> u64 {
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await?;
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = "subscription=\"licensed\"";
let url = format!(
"{pb_url}/api/collections/users/records?filter={}&perPage=1",

View file

@ -147,6 +147,7 @@ fn rebuild_data(shared: &SharedState, start: Instant) -> anyhow::Result<(usize,
poi_category_groups: Arc::clone(&old.poi_category_groups),
travel_time_store: Arc::clone(&old.travel_time_store),
token_cache: Arc::clone(&old.token_cache),
superuser_token_cache: Arc::clone(&old.superuser_token_cache),
// Config (cheap clone)
screenshot_url: old.screenshot_url.clone(),

View file

@ -8,7 +8,7 @@ use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
const CODE_LEN: usize = 8;
@ -42,14 +42,7 @@ pub async fn post_shorten(State(shared): State<Arc<SharedState>>, Json(req): Jso
let state = shared.load_state();
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
@ -102,14 +95,7 @@ pub async fn get_short_url(State(shared): State<Arc<SharedState>>, Path(code): P
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");

View file

@ -8,7 +8,7 @@ use hmac::{Hmac, Mac};
use sha2::Sha256;
use tracing::{info, warn};
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
type HmacSha256 = Hmac<Sha256>;
@ -31,6 +31,19 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
_ => return false,
};
// Reject webhooks older than 5 minutes to prevent replay attacks
if let Ok(ts_secs) = ts.parse::<i64>() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if (now - ts_secs).abs() > 300 {
return false;
}
} else {
return false;
}
// Compute expected signature: HMAC-SHA256(secret, "TIMESTAMP.PAYLOAD")
let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(payload));
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
@ -94,15 +107,7 @@ pub async fn post_stripe_webhook(
}
// Update user subscription to "licensed" via PocketBase superuser auth
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser in webhook: {err}");
@ -110,6 +115,7 @@ pub async fn post_stripe_webhook(
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let res = state
.http_client

View file

@ -9,6 +9,7 @@ use crate::auth::TokenCache;
use crate::data::{
POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
};
use crate::pocketbase::SuperuserTokenCache;
use crate::routes::FeaturesResponse;
use crate::utils::GridIndex;
@ -44,6 +45,8 @@ pub struct AppState {
pub travel_time_store: Arc<TravelTimeStore>,
/// Token validation cache (60s TTL)
pub token_cache: Arc<TokenCache>,
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
pub superuser_token_cache: Arc<SuperuserTokenCache>,
// --- Config (cheap to clone) ---
/// URL of the screenshot service (e.g. http://screenshot:8002)