lgtm 2
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 2m43s
CI / Check (push) Failing after 3m7s

This commit is contained in:
Andras Schmelczer 2026-05-14 22:39:41 +01:00
parent a8de0a614d
commit 3fa95819e3
30 changed files with 907 additions and 205 deletions

View file

@ -112,6 +112,13 @@ async fn validate_token(
}
pub async fn auth_middleware(req: Request, next: Next) -> Response {
if bypass_auth_for_path(req.uri().path()) {
let (mut parts, body) = req.into_parts();
parts.extensions.insert(OptionalUser(None));
let req = Request::from_parts(parts, body);
return next.run(req).await;
}
let state = req
.extensions()
.get::<Arc<crate::state::AppState>>()
@ -150,3 +157,19 @@ pub async fn auth_middleware(req: Request, next: Next) -> Response {
next.run(req).await
}
fn bypass_auth_for_path(path: &str) -> bool {
path == "/api/stripe-webhook"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bypass_auth_only_for_stripe_webhook() {
assert!(bypass_auth_for_path("/api/stripe-webhook"));
assert!(!bypass_auth_for_path("/api/checkout"));
assert!(!bypass_auth_for_path("/api/stripe-webhook/extra"));
}
}

View file

@ -34,6 +34,32 @@ pub enum CheckoutCompletion {
Rejected(String),
}
pub enum PaymentReversalOutcome {
Applied {
user_id: String,
},
AlreadyHandled {
user_id: String,
},
IgnoredPartialRefund {
user_id: String,
refunded_amount_pence: u64,
paid_amount_pence: u64,
},
NoMatchingCheckout,
NotReversible {
user_id: String,
status: String,
},
}
pub enum PaymentReinstatementOutcome {
Applied { user_id: String },
AlreadyHandled { user_id: String },
Ignored { user_id: String, reason: String },
NoMatchingCheckout,
}
pub struct VerifiedCheckout {
pub reservation_id: String,
pub user_id: String,
@ -54,6 +80,9 @@ struct PendingCheckout {
currency: String,
referral_invite_id: String,
status: String,
payment_intent_id: String,
paid_amount_pence: u64,
reversal_reason: String,
}
pub fn now_unix_secs() -> u64 {
@ -424,6 +453,14 @@ async fn complete_verified_checkout_locked(
return Err(anyhow!("checkout reservation is {}", live_checkout.status));
}
grant_license(state, &checkout.user_id).await?;
mark_checkout_completed(
state,
&checkout.reservation_id,
checkout.paid_amount_pence,
&checkout.payment_intent_id,
)
.await?;
if !checkout.referral_invite_id.is_empty() {
mark_referral_invite_used(
state,
@ -433,14 +470,6 @@ async fn complete_verified_checkout_locked(
)
.await?;
}
grant_license(state, &checkout.user_id).await?;
mark_checkout_completed(
state,
&checkout.reservation_id,
checkout.paid_amount_pence,
&checkout.payment_intent_id,
)
.await?;
Ok(())
}
@ -466,6 +495,135 @@ pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()
set_user_subscription(state, user_id, "licensed").await
}
pub async fn reverse_license_for_payment_intent(
state: &AppState,
payment_intent_id: &str,
reason: &str,
refunded_amount_pence: Option<u64>,
) -> anyhow::Result<PaymentReversalOutcome> {
if !is_safe_stripe_session_id(payment_intent_id) {
return Err(anyhow!("invalid Stripe payment intent id"));
}
if !is_safe_reversal_reason(reason) {
return Err(anyhow!("invalid Stripe reversal reason"));
}
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
let checkout = match find_checkout_by_payment_intent_or_checkout_session(
state,
payment_intent_id,
)
.await?
{
Some(checkout) => checkout,
None => return Ok(PaymentReversalOutcome::NoMatchingCheckout),
};
let paid_amount_pence = checkout
.paid_amount_pence
.max(checkout.expected_total_pence);
if let Some(refunded_amount_pence) = refunded_amount_pence {
if refunded_amount_pence < paid_amount_pence {
return Ok(PaymentReversalOutcome::IgnoredPartialRefund {
user_id: checkout.user_id,
refunded_amount_pence,
paid_amount_pence,
});
}
}
if checkout.status == "reversed" {
return Ok(PaymentReversalOutcome::AlreadyHandled {
user_id: checkout.user_id,
});
}
if matches!(checkout.status.as_str(), "pending" | "expired" | "failed") {
mark_checkout_reversed(state, &checkout.id, reason, payment_intent_id).await?;
return Ok(PaymentReversalOutcome::Applied {
user_id: checkout.user_id,
});
}
if checkout.status != "completed" {
return Ok(PaymentReversalOutcome::NotReversible {
user_id: checkout.user_id,
status: checkout.status,
});
}
let has_other_license = has_other_completed_checkout_for_user(
state,
&checkout.user_id,
&checkout.id,
payment_intent_id,
)
.await?;
if !has_other_license {
revoke_license(state, &checkout.user_id).await?;
}
mark_checkout_reversed(state, &checkout.id, reason, payment_intent_id).await?;
Ok(PaymentReversalOutcome::Applied {
user_id: checkout.user_id,
})
}
pub async fn reinstate_license_for_payment_intent(
state: &AppState,
payment_intent_id: &str,
reason: &str,
) -> anyhow::Result<PaymentReinstatementOutcome> {
if !is_safe_stripe_session_id(payment_intent_id) {
return Err(anyhow!("invalid Stripe payment intent id"));
}
if !is_safe_reversal_reason(reason) {
return Err(anyhow!("invalid Stripe reinstatement reason"));
}
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
let checkout = match find_checkout_by_payment_intent_or_checkout_session(
state,
payment_intent_id,
)
.await?
{
Some(checkout) => checkout,
None => return Ok(PaymentReinstatementOutcome::NoMatchingCheckout),
};
if checkout.status == "completed" {
return Ok(PaymentReinstatementOutcome::AlreadyHandled {
user_id: checkout.user_id,
});
}
if checkout.status != "reversed" {
return Ok(PaymentReinstatementOutcome::Ignored {
user_id: checkout.user_id,
reason: format!("checkout status is {}", checkout.status),
});
}
if !checkout.reversal_reason.starts_with("charge.dispute.") {
return Ok(PaymentReinstatementOutcome::Ignored {
user_id: checkout.user_id,
reason: format!("checkout was reversed by {}", checkout.reversal_reason),
});
}
grant_license(state, &checkout.user_id).await?;
mark_checkout_reinstated(state, &checkout.id, reason).await?;
Ok(PaymentReinstatementOutcome::Applied {
user_id: checkout.user_id,
})
}
async fn revoke_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
set_user_subscription(state, user_id, "free").await
}
async fn set_user_subscription(
state: &AppState,
user_id: &str,
@ -510,20 +668,34 @@ pub async fn mark_referral_invite_used(
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let invite = fetch_invite_record(state, pb_url, &token, invite_id).await?;
let existing_used_by = invite["used_by_id"].as_str().unwrap_or_default();
if existing_used_by == user_id {
return Ok(());
}
if !existing_used_by.is_empty() {
return Err(anyhow!("referral invite already used by another account"));
}
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
if !reserved_by_id.is_empty() && reserved_by_id != user_id {
return Err(anyhow!("referral invite reserved by another account"));
}
if !reserved_checkout_id.is_empty() && reserved_checkout_id != reservation_id {
return Err(anyhow!("referral invite reserved by another checkout"));
// A verified Stripe payment must not lose entitlement just because local
// invite reservation bookkeeping expired or moved before webhook delivery.
match referral_invite_completion_action(&invite, user_id, reservation_id) {
ReferralInviteCompletionAction::AlreadyRecorded => return Ok(()),
ReferralInviteCompletionAction::AlreadyUsedByAnother => {
warn!(
invite_id,
user_id,
existing_used_by = invite["used_by_id"].as_str().unwrap_or_default(),
"Referral invite was already used by another account; preserving verified checkout entitlement"
);
return Ok(());
}
ReferralInviteCompletionAction::Record {
reservation_reassigned,
} => {
if reservation_reassigned {
warn!(
invite_id,
user_id,
reservation_id,
reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default(),
reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default(),
"Referral invite reservation moved before webhook completion; verified checkout will consume it"
);
}
}
}
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
@ -546,6 +718,36 @@ pub async fn mark_referral_invite_used(
.context("PocketBase invite usage update failed")
}
#[derive(Debug, PartialEq, Eq)]
enum ReferralInviteCompletionAction {
AlreadyRecorded,
AlreadyUsedByAnother,
Record { reservation_reassigned: bool },
}
fn referral_invite_completion_action(
invite: &Value,
user_id: &str,
reservation_id: &str,
) -> ReferralInviteCompletionAction {
let existing_used_by = invite["used_by_id"].as_str().unwrap_or_default();
if existing_used_by == user_id {
return ReferralInviteCompletionAction::AlreadyRecorded;
}
if !existing_used_by.is_empty() {
return ReferralInviteCompletionAction::AlreadyUsedByAnother;
}
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
let reservation_reassigned = (!reserved_by_id.is_empty() && reserved_by_id != user_id)
|| (!reserved_checkout_id.is_empty() && reserved_checkout_id != reservation_id);
ReferralInviteCompletionAction::Record {
reservation_reassigned,
}
}
async fn fetch_invite_record(
state: &AppState,
pb_url: &str,
@ -1038,6 +1240,56 @@ async fn mark_checkout_status(
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
}
async fn mark_checkout_reversed(
state: &AppState,
reservation_id: &str,
reason: &str,
payment_intent_id: &str,
) -> anyhow::Result<()> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
let resp = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"status": "reversed",
"reversal_reason": reason,
"stripe_payment_intent_id": payment_intent_id,
}))
.send()
.await?;
ensure_success(resp)
.await
.with_context(|| format!("PocketBase checkout reversal update failed for {reservation_id}"))
}
async fn mark_checkout_reinstated(
state: &AppState,
reservation_id: &str,
_reason: &str,
) -> anyhow::Result<()> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
let resp = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"status": "completed",
"reversal_reason": "",
}))
.send()
.await?;
ensure_success(resp).await.with_context(|| {
format!("PocketBase checkout reinstatement update failed for {reservation_id}")
})
}
async fn find_checkout_by_stripe_session(
state: &AppState,
stripe_session_id: &str,
@ -1067,6 +1319,164 @@ async fn find_checkout_by_stripe_session(
item.map(parse_pending_checkout).transpose()
}
async fn find_checkout_by_payment_intent(
state: &AppState,
payment_intent_id: &str,
) -> anyhow::Result<Option<PendingCheckout>> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("stripe_payment_intent_id=\"{}\"", payment_intent_id);
let url = format!(
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
ensure_success_ref(&resp).await?;
let body: Value = resp.json().await?;
let item = body["items"]
.as_array()
.and_then(|items| items.first())
.cloned();
item.map(parse_pending_checkout).transpose()
}
async fn find_checkout_by_payment_intent_or_checkout_session(
state: &AppState,
payment_intent_id: &str,
) -> anyhow::Result<Option<PendingCheckout>> {
if let Some(checkout) = find_checkout_by_payment_intent(state, payment_intent_id).await? {
return Ok(Some(checkout));
}
let Some(session_id) =
fetch_stripe_checkout_session_id_for_payment_intent(state, payment_intent_id).await?
else {
return Ok(None);
};
let Some(mut checkout) = find_checkout_by_stripe_session(state, &session_id).await? else {
return Ok(None);
};
if checkout.payment_intent_id.is_empty() {
attach_payment_intent_to_checkout(state, &checkout.id, payment_intent_id).await?;
checkout.payment_intent_id = payment_intent_id.to_string();
} else if checkout.payment_intent_id != payment_intent_id {
mark_checkout_status(state, &checkout.id, "invalid").await?;
return Err(anyhow!(
"checkout reservation payment intent changed before reversal"
));
}
Ok(Some(checkout))
}
async fn fetch_stripe_checkout_session_id_for_payment_intent(
state: &AppState,
payment_intent_id: &str,
) -> anyhow::Result<Option<String>> {
let url = format!(
"https://api.stripe.com/v1/checkout/sessions?payment_intent={}&limit=1",
urlencoding::encode(payment_intent_id)
);
let resp = state
.http_client
.get(&url)
.basic_auth(&state.stripe_secret_key, None::<&str>)
.send()
.await
.context("Stripe checkout session lookup failed")?;
ensure_success_ref(&resp)
.await
.context("Stripe checkout session lookup returned error")?;
let body: Value = resp
.json()
.await
.context("Failed to parse Stripe checkout session lookup")?;
Ok(body["data"]
.as_array()
.and_then(|items| items.first())
.and_then(|item| item["id"].as_str())
.filter(|id| is_safe_stripe_session_id(id))
.map(str::to_string))
}
async fn attach_payment_intent_to_checkout(
state: &AppState,
reservation_id: &str,
payment_intent_id: &str,
) -> anyhow::Result<()> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
let resp = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"stripe_payment_intent_id": payment_intent_id,
}))
.send()
.await?;
ensure_success(resp)
.await
.context("PocketBase checkout payment intent attach failed")
}
async fn has_other_completed_checkout_for_user(
state: &AppState,
user_id: &str,
reservation_id: &str,
payment_intent_id: &str,
) -> anyhow::Result<bool> {
if !is_safe_pocketbase_id(user_id) || !is_safe_pocketbase_id(reservation_id) {
return Err(anyhow!("invalid PocketBase id"));
}
if !is_safe_stripe_session_id(payment_intent_id) {
return Err(anyhow!("invalid Stripe payment intent id"));
}
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("user=\"{user_id}\" && status=\"completed\"");
let url = format!(
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=50",
urlencoding::encode(&filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
ensure_success_ref(&resp).await?;
let body: Value = resp.json().await?;
let Some(items) = body["items"].as_array() else {
return Ok(false);
};
Ok(items.iter().any(|item| {
let other_id = item["id"].as_str().unwrap_or_default();
let other_payment_intent = item["stripe_payment_intent_id"]
.as_str()
.unwrap_or_default();
other_id != reservation_id && other_payment_intent != payment_intent_id
}))
}
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
Ok(PendingCheckout {
id: item["id"]
@ -1098,6 +1508,15 @@ fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
.unwrap_or_default()
.to_string(),
status: item["status"].as_str().unwrap_or_default().to_string(),
payment_intent_id: item["stripe_payment_intent_id"]
.as_str()
.unwrap_or_default()
.to_string(),
paid_amount_pence: number_field(&item, "paid_amount_pence").unwrap_or(0),
reversal_reason: item["reversal_reason"]
.as_str()
.unwrap_or_default()
.to_string(),
})
}
@ -1129,6 +1548,14 @@ fn is_safe_pocketbase_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 32 && id.bytes().all(|b| b.is_ascii_alphanumeric())
}
fn is_safe_reversal_reason(reason: &str) -> bool {
!reason.is_empty()
&& reason.len() <= 128
&& reason
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
}
async fn ensure_success(resp: reqwest::Response) -> anyhow::Result<()> {
if resp.status().is_success() {
return Ok(());
@ -1182,4 +1609,51 @@ mod tests {
assert_eq!(expected_total_for_checkout(1, Some("coupon_30")), 1);
assert_eq!(expected_total_for_checkout(999, None), 999);
}
#[test]
fn referral_invite_completion_records_available_invite() {
let invite = serde_json::json!({
"used_by_id": "",
"reserved_by_id": "",
"reserved_checkout_id": "",
});
assert_eq!(
referral_invite_completion_action(&invite, "user123", "checkout123"),
ReferralInviteCompletionAction::Record {
reservation_reassigned: false
}
);
}
#[test]
fn referral_invite_completion_records_reassigned_reservation() {
let invite = serde_json::json!({
"used_by_id": "",
"reserved_by_id": "otheruser",
"reserved_checkout_id": "othercheckout",
});
assert_eq!(
referral_invite_completion_action(&invite, "user123", "checkout123"),
ReferralInviteCompletionAction::Record {
reservation_reassigned: true
}
);
}
#[test]
fn referral_invite_completion_detects_existing_usage() {
let used_by_same_user = serde_json::json!({ "used_by_id": "user123" });
let used_by_another_user = serde_json::json!({ "used_by_id": "otheruser" });
assert_eq!(
referral_invite_completion_action(&used_by_same_user, "user123", "checkout123"),
ReferralInviteCompletionAction::AlreadyRecorded
);
assert_eq!(
referral_invite_completion_action(&used_by_another_user, "user123", "checkout123"),
ReferralInviteCompletionAction::AlreadyUsedByAnother
);
}
}

View file

@ -11,11 +11,8 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
pub const GRID_CELL_SIZE: f32 = 0.01;
pub const MAX_POIS_PER_REQUEST: usize = 10000;
pub const MAX_CELLS_PER_REQUEST: usize = 50000;
pub const MAX_ROWS_PER_BOUNDS_QUERY: usize = 2_000_000;
pub const MAX_ROWS_PER_EXPORT: usize = 250_000;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PROPERTIES_LIMIT: usize = 500;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
@ -30,3 +27,10 @@ pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1.
/// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
pub const SHARE_CACHE_TTL_SECS: u64 = 300;
pub const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
pub const MIN_SHARE_ZOOM: f64 = 11.0;
pub const MAX_SHARE_ZOOM: f64 = 20.0;
pub const MAX_SHARE_LAT_SPAN: f64 = 1.2;
pub const MAX_SHARE_LON_SPAN: f64 = 2.0;

View file

@ -6,6 +6,7 @@ mod checkout_sessions;
mod consts;
mod data;
mod features;
mod language;
mod licensing;
mod metrics;
mod og_middleware;
@ -496,7 +497,7 @@ async fn main() -> anyhow::Result<()> {
let shared = Arc::new(SharedState::new(app_state));
// Start background PocketBase metrics poller (users, saved searches/properties counts)
// Start background PocketBase metrics poller (users, saved searches counts)
pocketbase::start_metrics_poller(shared.clone());
let initial_state = shared.load_state();

View file

@ -1085,40 +1085,6 @@ pub async fn ensure_collections(
ensure_notes_field(client, base_url, &token, "saved_searches").await?;
}
if !existing.iter().any(|n| n == "saved_properties") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
let user_only = Some("user = @request.auth.id".to_string());
create_collection(
client,
base_url,
&token,
CreateCollection {
name: "saved_properties".to_string(),
r#type: "base".to_string(),
fields: vec![
Field::relation("user", &users_id),
Field::text("address", true),
Field::text("postcode", true),
Field::text("data", false),
Field::text("notes", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: user_only.clone(),
view_rule: user_only.clone(),
create_rule: user_only.clone(),
update_rule: user_only.clone(),
delete_rule: user_only,
indexes: Vec::new(),
},
)
.await?;
} else {
ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?;
ensure_autodate_fields(client, base_url, &token, "saved_properties").await?;
ensure_notes_field(client, base_url, &token, "saved_properties").await?;
}
if !existing.iter().any(|n| n == "invites") {
create_collection(
client,
@ -1460,7 +1426,6 @@ async fn poll_pocketbase_counts(state: &AppState) {
for (collection, metric_name) in [
("users", "pocketbase_users_total"),
("saved_searches", "pocketbase_saved_searches_total"),
("saved_properties", "pocketbase_saved_properties_total"),
] {
if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await {
gauge!(metric_name).set(total as f64);

View file

@ -14,6 +14,7 @@ use crate::state::SharedState;
#[derive(Deserialize)]
pub struct CheckoutRequest {
referral_code: Option<String>,
return_path: Option<String>,
}
#[derive(Serialize)]
@ -21,6 +22,27 @@ struct CheckoutResponse {
url: String,
}
fn sanitize_return_path(path: Option<&str>) -> &str {
let Some(path) = path else {
return "/pricing";
};
let path = path.split('#').next().unwrap_or(path);
if path.is_empty()
|| path.len() > 2048
|| !path.starts_with('/')
|| path.starts_with("//")
|| path.chars().any(char::is_control)
{
return "/pricing";
}
path
}
fn append_query_param(path: &str, key: &str, value: &str) -> String {
let separator = if path.contains('?') { '&' } else { '?' };
format!("{path}{separator}{key}={value}")
}
/// Create a reserved Stripe Checkout session for the lifetime license.
/// Requires authentication. Referral discounts are issued via invite redemption.
pub async fn post_checkout(
@ -34,9 +56,13 @@ pub async fn post_checkout(
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
let cancel_url = format!("{public_url}/pricing");
let public_url = state.public_url.trim_end_matches('/');
let return_path = sanitize_return_path(req.return_path.as_deref());
let success_url = format!(
"{public_url}{}",
append_query_param(return_path, "license_success", "1")
);
let cancel_url = format!("{public_url}{return_path}");
if req.referral_code.is_some() {
return (
@ -46,6 +72,10 @@ pub async fn post_checkout(
.into_response();
}
if user.is_admin || user.subscription == "licensed" {
return (StatusCode::CONFLICT, "This account already has full access").into_response();
}
match start_license_checkout(&state, &user, &success_url, &cancel_url, None, None).await {
Ok(CheckoutStart::Free) => {
info!(user_id = %user.id, "Granted free early-bird license");
@ -58,3 +88,38 @@ pub async fn post_checkout(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_return_path_accepts_local_paths_and_strips_fragments() {
assert_eq!(
sanitize_return_path(Some("/map?postcode=SW1A#details")),
"/map?postcode=SW1A"
);
}
#[test]
fn sanitize_return_path_rejects_external_or_control_paths() {
assert_eq!(sanitize_return_path(Some("//evil.test/path")), "/pricing");
assert_eq!(
sanitize_return_path(Some("https://evil.test/path")),
"/pricing"
);
assert_eq!(sanitize_return_path(Some("/map\nbad")), "/pricing");
}
#[test]
fn append_query_param_preserves_existing_query_separator() {
assert_eq!(
append_query_param("/map?postcode=SW1A", "license_success", "1"),
"/map?postcode=SW1A&license_success=1"
);
assert_eq!(
append_query_param("/pricing", "license_success", "1"),
"/pricing?license_success=1"
);
}
}

View file

@ -10,13 +10,13 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{
parse_field_set, parse_filters_with_poi, row_passes_filters, row_passes_poi_filters,
};
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::hexagon_stats::HexagonStatsResponse;
use super::hexagon_stats::{
parse_area_stats_field_set, top_filter_exclusions, HexagonStatsResponse,
};
use super::stats;
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
@ -24,8 +24,9 @@ use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_tra
pub struct PostcodeStatsParams {
pub postcode: String,
pub filters: Option<String>,
/// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned.
/// `;;`-separated feature names to include in stats response.
/// Only listed features are computed. If absent, area stats default to
/// displayable groups; if empty, no feature stats are returned.
pub fields: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
@ -80,7 +81,7 @@ pub async fn get_postcode_stats(
let filters_str = params.filters;
let has_poi_filters = !parsed_poi_filters.is_empty();
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
let (fields_specified, field_set) = parse_area_stats_field_set(params.fields.as_deref());
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
@ -100,26 +101,26 @@ pub async fn get_postcode_stats(
let min_lon = centroid_lon as f64 - offset;
let max_lon = centroid_lon as f64 + offset;
let mut area_rows: Vec<usize> = Vec::new();
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
let row_postcode = state.data.postcode(row);
if row_postcode == postcode_str
&& row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
)
&& (!has_poi_filters
|| row_passes_poi_filters(
row,
&parsed_poi_filters,
&state.data.poi_metrics,
))
if row_postcode != postcode_str {
return;
}
area_rows.push(row);
if row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) && (!has_poi_filters
|| row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics))
{
if has_travel
&& !row_passes_travel_filters(row_postcode, &travel_entries, &travel_data)
@ -131,6 +132,19 @@ pub async fn get_postcode_stats(
});
let total_count = matching_rows.len();
let filter_exclusions = if total_count == 0 {
top_filter_exclusions(
&area_rows,
&parsed_filters,
&parsed_enum_filters,
&parsed_poi_filters,
&travel_entries,
&travel_data,
&state.data,
)
} else {
Vec::new()
};
let price_history =
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
@ -168,6 +182,7 @@ pub async fn get_postcode_stats(
enum_features: enum_features_out,
price_history,
central_postcode: None,
filter_exclusions,
})
})
.await

View file

@ -33,6 +33,7 @@ pub struct PostcodesResponse {
pub struct NearestPostcodeParams {
lat: f64,
lng: f64,
log: Option<bool>,
}
#[derive(Deserialize)]
@ -468,15 +469,17 @@ pub async fn get_nearest_postcode(
let postcode = &postcode_data.postcodes[idx];
// Log location for authenticated users (best-effort, non-blocking)
if let Some(ref pb_user) = user.0 {
let state = state.clone();
let user_id = pb_user.id.clone();
let lat_f64 = params.lat;
let lng_f64 = params.lng;
let pc = postcode.clone();
tokio::spawn(async move {
log_user_location(&state, &user_id, lat_f64, lng_f64, &pc).await;
});
if params.log.unwrap_or(true) {
if let Some(ref pb_user) = user.0 {
let state = state.clone();
let user_id = pb_user.id.clone();
let lat_f64 = params.lat;
let lng_f64 = params.lng;
let pc = postcode.clone();
tokio::spawn(async move {
log_user_location(&state, &user_id, lat_f64, lng_f64, &pc).await;
});
}
}
info!(postcode = %postcode, "GET /api/nearest-postcode");

View file

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
use crate::consts::DEFAULT_PROPERTIES_LIMIT;
use crate::data::RenovationEvent;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
@ -273,10 +273,7 @@ pub async fn get_hexagon_properties(
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
let total = matching_rows.len();
let limit = params
.limit
.unwrap_or(DEFAULT_PROPERTIES_LIMIT)
.min(MAX_PROPERTIES_LIMIT);
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
let offset = params.offset.unwrap_or(0);
let truncated = total > offset + limit;

View file

@ -14,6 +14,7 @@ use crate::utils::normalize_postcode;
const RIGHTMOVE_TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
const RIGHTMOVE_HOST: &str = "www.rightmove.co.uk";
const RIGHTMOVE_FIND_PATH: &str = "/property-for-sale/find.html";
const RIGHTMOVE_POSTCODE_RADIUS_MILES: &str = "0.25";
#[derive(Deserialize)]
pub struct RightmoveRedirectParams {
@ -151,7 +152,10 @@ fn apply_exact_postcode_location(url: &mut Url, postcode: &str, location_identif
"locationIdentifier".to_string(),
location_identifier.to_string(),
));
pairs.push(("radius".to_string(), "0.0".to_string()));
pairs.push((
"radius".to_string(),
RIGHTMOVE_POSTCODE_RADIUS_MILES.to_string(),
));
let mut query = url.query_pairs_mut();
query.clear();
@ -200,7 +204,7 @@ mod tests {
#[test]
fn rewrites_rightmove_url_to_exact_postcode_location() {
let mut url = Url::parse(
"https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.25&minPrice=100000",
"https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.0&minPrice=100000",
)
.unwrap();
@ -210,7 +214,7 @@ mod tests {
assert_eq!(pairs.get("searchLocation").unwrap(), "SW1A 1AA");
assert_eq!(pairs.get("useLocationIdentifier").unwrap(), "true");
assert_eq!(pairs.get("locationIdentifier").unwrap(), "POSTCODE^837246");
assert_eq!(pairs.get("radius").unwrap(), "0.0");
assert_eq!(pairs.get("radius").unwrap(), "0.25");
assert_eq!(pairs.get("minPrice").unwrap(), "100000");
}