More fixes
This commit is contained in:
parent
15fa09430b
commit
6b12e21d50
54 changed files with 1665 additions and 630 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue