deploy
This commit is contained in:
parent
3fa95819e3
commit
e9a06417ad
32 changed files with 1531 additions and 407 deletions
|
|
@ -3,12 +3,15 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{parse_filters_with_poi, require_bounds};
|
||||
use crate::routes::travel_time::parse_optional_travel;
|
||||
use crate::state::SharedState;
|
||||
|
|
@ -18,6 +21,7 @@ pub struct FilterCountsParams {
|
|||
bounds: Option<String>,
|
||||
filters: Option<String>,
|
||||
travel: Option<String>,
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -28,12 +32,15 @@ pub struct FilterCountsResponse {
|
|||
|
||||
pub async fn get_filter_counts(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<FilterCountsParams>,
|
||||
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -11,15 +11,25 @@ use serde::{Deserialize, Serialize};
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::data::PropertyData;
|
||||
use crate::features::{Feature, FEATURE_GROUPS};
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters_with_poi,
|
||||
row_passes_filters, row_passes_poi_filters, validate_h3_resolution,
|
||||
row_passes_filters, row_passes_poi_filters, validate_h3_resolution, ParsedEnumFilter,
|
||||
ParsedFilter, ParsedPoiFilter,
|
||||
};
|
||||
use crate::state::SharedState;
|
||||
|
||||
use super::stats;
|
||||
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
||||
use super::travel_time::{
|
||||
load_travel_data, parse_optional_travel, row_passes_travel_filters, TravelEntry,
|
||||
};
|
||||
|
||||
const AREA_STATS_EXCLUDED_GROUPS: &[&str] = &["Amenities"];
|
||||
const MAX_FILTER_EXCLUSIONS: usize = 5;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HistogramStats {
|
||||
|
|
@ -54,6 +64,47 @@ pub struct PricePoint {
|
|||
pub price: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FilterExclusion {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub direction: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
pub relative_difference: f32,
|
||||
pub rejected_count: usize,
|
||||
}
|
||||
|
||||
fn filter_exclusion_key(exclusion: &FilterExclusion) -> String {
|
||||
format!(
|
||||
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
|
||||
exclusion.kind,
|
||||
exclusion.name,
|
||||
exclusion.direction,
|
||||
exclusion.category.as_deref().unwrap_or("")
|
||||
)
|
||||
}
|
||||
|
||||
fn missing_filter_exclusion(name: String, kind: &str) -> FilterExclusion {
|
||||
FilterExclusion {
|
||||
name,
|
||||
kind: kind.to_string(),
|
||||
direction: "missing_value".to_string(),
|
||||
value: None,
|
||||
min: None,
|
||||
max: None,
|
||||
category: None,
|
||||
relative_difference: 1.0,
|
||||
rejected_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HexagonStatsResponse {
|
||||
pub count: usize,
|
||||
|
|
@ -63,6 +114,8 @@ pub struct HexagonStatsResponse {
|
|||
pub price_history: Vec<PricePoint>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub central_postcode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub filter_exclusions: Vec<FilterExclusion>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -70,8 +123,9 @@ pub struct HexagonStatsParams {
|
|||
pub h3: String,
|
||||
pub resolution: u8,
|
||||
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>,
|
||||
/// When set (with journey_slug), pick central_postcode as the postcode with the
|
||||
/// shortest travel time for this mode+slug (so it has journey data).
|
||||
|
|
@ -84,6 +138,271 @@ pub struct HexagonStatsParams {
|
|||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
fn default_area_stat_field_set() -> HashSet<String> {
|
||||
FEATURE_GROUPS
|
||||
.iter()
|
||||
.filter(|group| !AREA_STATS_EXCLUDED_GROUPS.contains(&group.name))
|
||||
.flat_map(|group| group.features.iter())
|
||||
.map(|feature| match feature {
|
||||
Feature::Numeric(config) => config.name.to_string(),
|
||||
Feature::Enum(config) => config.name.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn parse_area_stats_field_set(fields: Option<&str>) -> (bool, HashSet<String>) {
|
||||
let (fields_specified, field_set) = parse_field_set(fields);
|
||||
if fields_specified {
|
||||
return (fields_specified, field_set);
|
||||
}
|
||||
|
||||
(true, default_area_stat_field_set())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn relative_difference(value: f32, min: f32, max: f32) -> Option<(String, f32)> {
|
||||
let distance = if value < min {
|
||||
min - value
|
||||
} else if value > max {
|
||||
value - max
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let range = (max - min).abs();
|
||||
let denominator = if range.is_finite() && range > f32::EPSILON {
|
||||
range
|
||||
} else {
|
||||
min.abs().max(max.abs()).max(1.0)
|
||||
};
|
||||
let direction = if value < min {
|
||||
"lower_min".to_string()
|
||||
} else {
|
||||
"raise_max".to_string()
|
||||
};
|
||||
Some((direction, distance / denominator))
|
||||
}
|
||||
|
||||
pub(super) fn top_filter_exclusions(
|
||||
area_rows: &[usize],
|
||||
numeric_filters: &[ParsedFilter],
|
||||
enum_filters: &[ParsedEnumFilter],
|
||||
poi_filters: &[ParsedPoiFilter],
|
||||
travel_entries: &[TravelEntry],
|
||||
travel_data: &[TravelData],
|
||||
data: &PropertyData,
|
||||
) -> Vec<FilterExclusion> {
|
||||
if area_rows.is_empty()
|
||||
|| (numeric_filters.is_empty()
|
||||
&& enum_filters.is_empty()
|
||||
&& poi_filters.is_empty()
|
||||
&& !travel_entries
|
||||
.iter()
|
||||
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some()))
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let feature_data = &data.feature_data;
|
||||
let num_features = data.num_features;
|
||||
let quant = data.quant_ref();
|
||||
let poi_quant = data.poi_metrics.quant_ref();
|
||||
let mut rejection_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut best_path: Option<Vec<FilterExclusion>> = None;
|
||||
|
||||
for &row in area_rows {
|
||||
let mut path = Vec::new();
|
||||
|
||||
for filter in numeric_filters {
|
||||
let min = quant.decode(filter.feat_idx, filter.min_u16);
|
||||
let max = quant.decode(filter.feat_idx, filter.max_u16);
|
||||
let raw = feature_data[row * num_features + filter.feat_idx];
|
||||
if raw == NAN_U16 {
|
||||
path.push(missing_filter_exclusion(
|
||||
data.feature_names[filter.feat_idx].clone(),
|
||||
"numeric",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = quant.decode(filter.feat_idx, raw);
|
||||
let Some((direction, rel_diff)) = relative_difference(value, min, max) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
path.push(FilterExclusion {
|
||||
name: data.feature_names[filter.feat_idx].clone(),
|
||||
kind: "numeric".to_string(),
|
||||
direction,
|
||||
value: Some(value),
|
||||
min: Some(min),
|
||||
max: Some(max),
|
||||
category: None,
|
||||
relative_difference: rel_diff,
|
||||
rejected_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for filter in enum_filters {
|
||||
let raw = feature_data[row * num_features + filter.feat_idx];
|
||||
if raw == NAN_U16 {
|
||||
path.push(missing_filter_exclusion(
|
||||
data.feature_names[filter.feat_idx].clone(),
|
||||
"enum",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if filter.allowed.contains(&raw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(values) = data.enum_values.get(&filter.feat_idx) else {
|
||||
continue;
|
||||
};
|
||||
let Some(category) = values.get(raw as usize) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
path.push(FilterExclusion {
|
||||
name: data.feature_names[filter.feat_idx].clone(),
|
||||
kind: "enum".to_string(),
|
||||
direction: "allow_value".to_string(),
|
||||
value: None,
|
||||
min: None,
|
||||
max: None,
|
||||
category: Some(category.clone()),
|
||||
relative_difference: 1.0,
|
||||
rejected_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for filter in poi_filters {
|
||||
let min = poi_quant.decode(filter.metric_idx, filter.min_u16);
|
||||
let max = poi_quant.decode(filter.metric_idx, filter.max_u16);
|
||||
let raw = data
|
||||
.poi_metrics
|
||||
.raw_for_property_row(row, filter.metric_idx);
|
||||
if raw == NAN_U16 {
|
||||
path.push(missing_filter_exclusion(
|
||||
data.poi_metrics.feature_names[filter.metric_idx].clone(),
|
||||
"poi",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = poi_quant.decode(filter.metric_idx, raw);
|
||||
let Some((direction, rel_diff)) = relative_difference(value, min, max) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
path.push(FilterExclusion {
|
||||
name: data.poi_metrics.feature_names[filter.metric_idx].clone(),
|
||||
kind: "poi".to_string(),
|
||||
direction,
|
||||
value: Some(value),
|
||||
min: Some(min),
|
||||
max: Some(max),
|
||||
category: None,
|
||||
relative_difference: rel_diff,
|
||||
rejected_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (filter_index, entry) in travel_entries.iter().enumerate() {
|
||||
let (Some(min), Some(max)) = (entry.filter_min, entry.filter_max) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let postcode = data.postcode(row);
|
||||
let Some(row_data) = travel_data
|
||||
.get(filter_index)
|
||||
.and_then(|travel| travel.get(postcode))
|
||||
else {
|
||||
path.push(missing_filter_exclusion(
|
||||
format!("tt_{}_{}", entry.mode, entry.slug),
|
||||
"travel",
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
let minutes = if entry.use_best {
|
||||
row_data.best_minutes.unwrap_or(row_data.minutes)
|
||||
} else {
|
||||
row_data.minutes
|
||||
} as f32;
|
||||
let Some((direction, rel_diff)) = relative_difference(minutes, min, max) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
path.push(FilterExclusion {
|
||||
name: format!("tt_{}_{}", entry.mode, entry.slug),
|
||||
kind: "travel".to_string(),
|
||||
direction,
|
||||
value: Some(minutes),
|
||||
min: Some(min),
|
||||
max: Some(max),
|
||||
category: None,
|
||||
relative_difference: rel_diff,
|
||||
rejected_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for exclusion in &path {
|
||||
*rejection_counts
|
||||
.entry(filter_exclusion_key(exclusion))
|
||||
.or_default() += 1;
|
||||
}
|
||||
|
||||
let path_score = path
|
||||
.iter()
|
||||
.map(|exclusion| exclusion.relative_difference)
|
||||
.sum::<f32>();
|
||||
let current_score = best_path
|
||||
.as_ref()
|
||||
.map(|current| {
|
||||
current
|
||||
.iter()
|
||||
.map(|exclusion| exclusion.relative_difference)
|
||||
.sum::<f32>()
|
||||
})
|
||||
.unwrap_or(f32::INFINITY);
|
||||
|
||||
let replace = path_score < current_score
|
||||
|| (path_score == current_score
|
||||
&& best_path
|
||||
.as_ref()
|
||||
.map_or(true, |current| path.len() < current.len()));
|
||||
if replace {
|
||||
best_path = Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(mut exclusions) = best_path else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
for exclusion in &mut exclusions {
|
||||
exclusion.rejected_count = rejection_counts
|
||||
.get(&filter_exclusion_key(exclusion))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
}
|
||||
|
||||
exclusions.sort_by(|a, b| {
|
||||
a.relative_difference
|
||||
.partial_cmp(&b.relative_difference)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
exclusions.truncate(MAX_FILTER_EXCLUSIONS);
|
||||
exclusions
|
||||
}
|
||||
|
||||
pub async fn get_hexagon_stats(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
|
|
@ -124,7 +443,7 @@ pub async fn get_hexagon_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())?;
|
||||
|
|
@ -151,38 +470,55 @@ pub async fn get_hexagon_stats(
|
|||
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
|
||||
|
||||
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
||||
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;
|
||||
if cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache)
|
||||
== cell_u64
|
||||
&& 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,
|
||||
))
|
||||
!= cell_u64
|
||||
{
|
||||
if has_travel {
|
||||
let postcode = state.data.postcode(row);
|
||||
if !row_passes_travel_filters(postcode, &travel_entries, &travel_data) {
|
||||
return;
|
||||
}
|
||||
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(
|
||||
state.data.postcode(row),
|
||||
&travel_entries,
|
||||
&travel_data,
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
matching_rows.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
// Pick central_postcode: prefer the postcode with the shortest travel time
|
||||
// for the requested journey destination (so it has journey data). Fall back
|
||||
|
|
@ -277,6 +613,7 @@ pub async fn get_hexagon_stats(
|
|||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
central_postcode,
|
||||
filter_exclusions,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
|
@ -285,3 +622,30 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_area_stat_fields_skip_amenities() {
|
||||
let (fields_specified, field_set) = parse_area_stats_field_set(None);
|
||||
|
||||
assert!(fields_specified);
|
||||
assert!(field_set.contains("Property type"));
|
||||
assert!(!field_set.contains("Noise (dB)"));
|
||||
assert!(!field_set.contains("Max available download speed (Mbps)"));
|
||||
assert!(!field_set.contains("Distance to nearest amenity (Cafe) (km)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_area_stat_fields_are_respected() {
|
||||
let (fields_specified, field_set) =
|
||||
parse_area_stats_field_set(Some("Noise (dB);;Property type"));
|
||||
|
||||
assert!(fields_specified);
|
||||
assert!(field_set.contains("Noise (dB)"));
|
||||
assert!(field_set.contains("Property type"));
|
||||
assert_eq!(field_set.len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Extension;
|
||||
use axum::Json;
|
||||
|
|
@ -11,7 +11,8 @@ use tracing::warn;
|
|||
use url::form_urlencoded;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::licensing::{is_valid_share_bounds, share_bounds_from_params, ShareBounds};
|
||||
use crate::language::{language_from_accept_language, query_string_with_language};
|
||||
use crate::licensing::{is_valid_share_bounds, share_params_and_bounds_from_params, ShareBounds};
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::SharedState;
|
||||
|
||||
|
|
@ -136,6 +137,7 @@ fn is_allowed_param_key(key: &str) -> bool {
|
|||
| "tab"
|
||||
| "pc"
|
||||
| "tt"
|
||||
| "lang"
|
||||
| "share"
|
||||
)
|
||||
}
|
||||
|
|
@ -180,15 +182,42 @@ fn record_share_bounds(item: &serde_json::Value) -> Option<ShareBounds> {
|
|||
}
|
||||
|
||||
fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> String {
|
||||
match (params.is_empty(), include_share) {
|
||||
(true, false) => "/dashboard".to_string(),
|
||||
(true, true) => format!("/dashboard?share={code}"),
|
||||
(false, false) => format!("/dashboard?{params}"),
|
||||
(false, true) => format!("/dashboard?{params}&share={code}"),
|
||||
let params = match include_share {
|
||||
true => params_with_share(params, code),
|
||||
false => params.to_string(),
|
||||
};
|
||||
|
||||
if params.is_empty() {
|
||||
"/dashboard".to_string()
|
||||
} else {
|
||||
format!("/dashboard?{params}")
|
||||
}
|
||||
}
|
||||
|
||||
fn og_image_url(public_url: &str, params: &str) -> String {
|
||||
fn params_with_share(params: &str, code: &str) -> String {
|
||||
let mut out = form_urlencoded::Serializer::new(String::new());
|
||||
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
|
||||
if key == "share" {
|
||||
continue;
|
||||
}
|
||||
out.append_pair(&key, &value);
|
||||
}
|
||||
out.append_pair("share", code);
|
||||
out.finish()
|
||||
}
|
||||
|
||||
fn og_image_url(
|
||||
public_url: &str,
|
||||
params: &str,
|
||||
language: &str,
|
||||
share_code: Option<&str>,
|
||||
) -> String {
|
||||
let params = query_string_with_language(params, language);
|
||||
let params = match share_code {
|
||||
Some(code) => params_with_share(¶ms, code),
|
||||
None => params,
|
||||
};
|
||||
|
||||
if params.is_empty() {
|
||||
format!("{}/api/screenshot?og=1", public_url.trim_end_matches('/'))
|
||||
} else {
|
||||
|
|
@ -208,7 +237,7 @@ pub async fn post_shorten(
|
|||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let can_create_share_grant = user_can_create_share_grant(&user);
|
||||
let params = match sanitized_query_params(&req.params, !can_create_share_grant) {
|
||||
let mut params = match sanitized_query_params(&req.params, !can_create_share_grant) {
|
||||
Ok(params) => params,
|
||||
Err(reason) => {
|
||||
warn!("Rejected short URL params: {reason}");
|
||||
|
|
@ -226,7 +255,10 @@ pub async fn post_shorten(
|
|||
|
||||
let code = generate_code();
|
||||
let share_bounds = if can_create_share_grant {
|
||||
share_bounds_from_params(¶ms)
|
||||
share_params_and_bounds_from_params(¶ms).map(|(share_params, share_bounds)| {
|
||||
params = share_params;
|
||||
share_bounds
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -275,6 +307,7 @@ pub async fn post_shorten(
|
|||
pub async fn get_share_links(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let state = shared.load_state();
|
||||
let user = match user.0 {
|
||||
|
|
@ -328,6 +361,11 @@ pub async fn get_share_links(
|
|||
};
|
||||
|
||||
let public_url = state.public_url.trim_end_matches('/');
|
||||
let language = language_from_accept_language(
|
||||
headers
|
||||
.get(header::ACCEPT_LANGUAGE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let links: Vec<ShareLinkListItem> = body["items"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
|
|
@ -335,10 +373,17 @@ pub async fn get_share_links(
|
|||
.map(|item| {
|
||||
let code = item["code"].as_str().unwrap_or("").to_string();
|
||||
let params = item["params"].as_str().unwrap_or("").to_string();
|
||||
let has_share_grant = record_share_bounds(item).is_some();
|
||||
let og_image_url = og_image_url(
|
||||
public_url,
|
||||
¶ms,
|
||||
language,
|
||||
has_share_grant.then_some(code.as_str()),
|
||||
);
|
||||
ShareLinkListItem {
|
||||
url: format!("{public_url}/s/{code}"),
|
||||
code,
|
||||
og_image_url: og_image_url(public_url, ¶ms),
|
||||
og_image_url,
|
||||
params,
|
||||
click_count: json_number_as_u64(&item["click_count"]),
|
||||
created: item["created"].as_str().unwrap_or("").to_string(),
|
||||
|
|
@ -354,8 +399,14 @@ pub async fn get_share_links(
|
|||
pub async fn get_short_url(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Path(code): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let state = shared.load_state();
|
||||
let language = language_from_accept_language(
|
||||
headers
|
||||
.get(header::ACCEPT_LANGUAGE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
|
||||
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
|
|
@ -428,9 +479,14 @@ pub async fn get_short_url(
|
|||
Err(err) => warn!("PocketBase click count update failed: {err}"),
|
||||
}
|
||||
}
|
||||
let redirect_url =
|
||||
dashboard_redirect_url(¶ms, &code, record_share_bounds(item).is_some());
|
||||
let og_image_url = og_image_url(&state.public_url, ¶ms);
|
||||
let has_share_grant = record_share_bounds(item).is_some();
|
||||
let redirect_url = dashboard_redirect_url(¶ms, &code, has_share_grant);
|
||||
let og_image_url = og_image_url(
|
||||
&state.public_url,
|
||||
¶ms,
|
||||
language,
|
||||
has_share_grant.then_some(code.as_str()),
|
||||
);
|
||||
let og_url = format!("{}/s/{code}", state.public_url.trim_end_matches('/'));
|
||||
let og_title = "Perfect Postcode | Every neighbourhood in England";
|
||||
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
|
||||
|
|
@ -454,6 +510,7 @@ pub async fn get_short_url(
|
|||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{og_title}" />
|
||||
<meta name="twitter:description" content="{og_description}" />
|
||||
<meta name="twitter:image" content="{og_image_url}" />
|
||||
<meta http-equiv="refresh" content="0;url={redirect_url}" />
|
||||
<title>{og_title}</title>
|
||||
</head><body></body></html>"#
|
||||
|
|
@ -517,4 +574,34 @@ mod tests {
|
|||
fn escapes_html_attributes() {
|
||||
assert_eq!(escape_attr(r#""'><&"#), ""'><&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn og_image_url_includes_language_and_share_grant() {
|
||||
assert_eq!(
|
||||
og_image_url(
|
||||
"http://localhost:3001/",
|
||||
"lat=51.5&lon=-0.1&zoom=12",
|
||||
"de",
|
||||
Some("abc123")
|
||||
),
|
||||
"http://localhost:3001/api/screenshot?og=1&lat=51.5&lon=-0.1&zoom=12&lang=de&share=abc123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_grant_replaces_existing_share_param() {
|
||||
assert_eq!(
|
||||
dashboard_redirect_url("lat=51.5&share=oldcode&zoom=12", "newcode", true),
|
||||
"/dashboard?lat=51.5&zoom=12&share=newcode"
|
||||
);
|
||||
assert_eq!(
|
||||
og_image_url(
|
||||
"https://perfect-postcodes.co.uk",
|
||||
"lat=51.5&share=oldcode&zoom=12",
|
||||
"en",
|
||||
Some("newcode")
|
||||
),
|
||||
"https://perfect-postcodes.co.uk/api/screenshot?og=1&lat=51.5&zoom=12&lang=en&share=newcode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ use sha2::Sha256;
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::checkout_sessions::{
|
||||
complete_verified_checkout, reverse_license_for_payment_intent, verify_checkout_completion,
|
||||
CheckoutCompletion,
|
||||
complete_verified_checkout, reinstate_license_for_payment_intent,
|
||||
reverse_license_for_payment_intent, verify_checkout_completion, CheckoutCompletion,
|
||||
PaymentReinstatementOutcome, PaymentReversalOutcome,
|
||||
};
|
||||
use crate::state::SharedState;
|
||||
use crate::state::{AppState, SharedState};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
|
|
@ -73,12 +74,17 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
|
|||
matched
|
||||
}
|
||||
|
||||
fn payment_intent_id_from_object(object: &serde_json::Value) -> Option<&str> {
|
||||
object["payment_intent"]
|
||||
fn stripe_id_from_value(value: &serde_json::Value) -> Option<&str> {
|
||||
value
|
||||
.as_str()
|
||||
.or_else(|| value["id"].as_str())
|
||||
.filter(|id| is_safe_stripe_id(id))
|
||||
}
|
||||
|
||||
fn payment_intent_id_from_object(object: &serde_json::Value) -> Option<&str> {
|
||||
stripe_id_from_value(&object["payment_intent"])
|
||||
}
|
||||
|
||||
fn is_safe_stripe_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
|
|
@ -87,21 +93,169 @@ fn is_safe_stripe_id(id: &str) -> bool {
|
|||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
fn reversal_event_is_actionable(event_type: &str, object: &serde_json::Value) -> bool {
|
||||
fn integer_field(value: &serde_json::Value, field: &str) -> Option<u64> {
|
||||
value[field].as_u64().or_else(|| {
|
||||
value[field]
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0)
|
||||
.map(|n| n as u64)
|
||||
})
|
||||
}
|
||||
|
||||
fn reversal_refund_amount_pence(
|
||||
event_type: &str,
|
||||
object: &serde_json::Value,
|
||||
) -> Option<Option<u64>> {
|
||||
match event_type {
|
||||
"charge.refunded" => {
|
||||
object["refunded"].as_bool().unwrap_or(false)
|
||||
|| object["amount_refunded"].as_u64().unwrap_or(0) > 0
|
||||
let amount_refunded = integer_field(object, "amount_refunded").unwrap_or(0);
|
||||
let amount = integer_field(object, "amount").unwrap_or(0);
|
||||
if object["refunded"].as_bool().unwrap_or(false)
|
||||
|| (amount > 0 && amount_refunded >= amount)
|
||||
{
|
||||
Some(Some(amount_refunded))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
"charge.refund.updated" | "refund.created" | "refund.updated" => {
|
||||
matches!(object["status"].as_str(), Some("succeeded"))
|
||||
if matches!(object["status"].as_str(), Some("succeeded")) {
|
||||
integer_field(object, "amount").map(Some)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
"charge.dispute.created" | "charge.dispute.funds_withdrawn" => true,
|
||||
"charge.dispute.closed" => matches!(object["status"].as_str(), Some("lost")),
|
||||
"charge.dispute.created" | "charge.dispute.funds_withdrawn" => Some(None),
|
||||
"charge.dispute.closed" => {
|
||||
if matches!(object["status"].as_str(), Some("lost")) {
|
||||
Some(None)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reinstatement_event_is_actionable(event_type: &str, object: &serde_json::Value) -> bool {
|
||||
match event_type {
|
||||
"charge.dispute.funds_reinstated" => true,
|
||||
"charge.dispute.closed" => matches!(object["status"].as_str(), Some("won")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_stripe_reversal_event(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
event_type: &str,
|
||||
refunded_amount_pence: Option<u64>,
|
||||
) -> Result<(), StatusCode> {
|
||||
match reverse_license_for_payment_intent(
|
||||
state,
|
||||
payment_intent_id,
|
||||
event_type,
|
||||
refunded_amount_pence,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(PaymentReversalOutcome::Applied { user_id }) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id, event_type, "Processed Stripe payment reversal event"
|
||||
);
|
||||
}
|
||||
Ok(PaymentReversalOutcome::AlreadyHandled { user_id }) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id, event_type, "Stripe payment reversal was already handled"
|
||||
);
|
||||
}
|
||||
Ok(PaymentReversalOutcome::IgnoredPartialRefund {
|
||||
user_id,
|
||||
refunded_amount_pence,
|
||||
paid_amount_pence,
|
||||
}) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id,
|
||||
refunded_amount_pence,
|
||||
paid_amount_pence,
|
||||
"Ignoring partial Stripe refund"
|
||||
);
|
||||
}
|
||||
Ok(PaymentReversalOutcome::NotReversible { user_id, status }) => {
|
||||
warn!(
|
||||
user_id,
|
||||
payment_intent_id,
|
||||
status,
|
||||
event_type,
|
||||
"Stripe reversal event matched a non-reversible checkout"
|
||||
);
|
||||
}
|
||||
Ok(PaymentReversalOutcome::NoMatchingCheckout) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Stripe reversal event had no matching checkout reservation"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Failed to process Stripe payment reversal event: {err:?}"
|
||||
);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_stripe_reinstatement_event(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
event_type: &str,
|
||||
) -> Result<(), StatusCode> {
|
||||
match reinstate_license_for_payment_intent(state, payment_intent_id, event_type).await {
|
||||
Ok(PaymentReinstatementOutcome::Applied { user_id }) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id, event_type, "Processed Stripe payment reinstatement event"
|
||||
);
|
||||
}
|
||||
Ok(PaymentReinstatementOutcome::AlreadyHandled { user_id }) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id, event_type, "Stripe payment reinstatement was already handled"
|
||||
);
|
||||
}
|
||||
Ok(PaymentReinstatementOutcome::Ignored { user_id, reason }) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id,
|
||||
event_type,
|
||||
reason,
|
||||
"Ignoring Stripe payment reinstatement event"
|
||||
);
|
||||
}
|
||||
Ok(PaymentReinstatementOutcome::NoMatchingCheckout) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Stripe reinstatement event had no matching checkout reservation"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Failed to process Stripe payment reinstatement event: {err:?}"
|
||||
);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Stripe webhook events.
|
||||
/// On `checkout.session.completed`, updates the user's subscription to "licensed".
|
||||
pub async fn post_stripe_webhook(
|
||||
|
|
@ -159,7 +313,15 @@ pub async fn post_stripe_webhook(
|
|||
"User subscription updated to licensed via verified Stripe checkout"
|
||||
);
|
||||
}
|
||||
Ok(CheckoutCompletion::AlreadyHandled) => {
|
||||
Ok(CheckoutCompletion::AlreadyHandled(checkout)) => {
|
||||
if let Err(err) = complete_verified_checkout(&state, &checkout).await {
|
||||
warn!(
|
||||
user_id = %checkout.user_id,
|
||||
reservation_id = %checkout.reservation_id,
|
||||
"Failed to finish idempotent Stripe checkout side effects: {err:?}"
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
info!("Stripe checkout session was already handled");
|
||||
}
|
||||
Ok(CheckoutCompletion::Rejected(reason)) => {
|
||||
|
|
@ -179,44 +341,173 @@ pub async fn post_stripe_webhook(
|
|||
| "charge.dispute.created"
|
||||
| "charge.dispute.closed"
|
||||
| "charge.dispute.funds_withdrawn"
|
||||
| "charge.dispute.funds_reinstated"
|
||||
) {
|
||||
let object = &event["data"]["object"];
|
||||
let Some(payment_intent_id) = payment_intent_id_from_object(object) else {
|
||||
warn!(
|
||||
event_id,
|
||||
event_type, "Stripe reversal event missing payment intent id"
|
||||
event_type, "Stripe payment adjustment event missing payment intent id"
|
||||
);
|
||||
return StatusCode::OK.into_response();
|
||||
};
|
||||
if !reversal_event_is_actionable(event_type, object) {
|
||||
info!(
|
||||
payment_intent_id,
|
||||
event_type, "Ignoring non-final Stripe reversal event"
|
||||
);
|
||||
|
||||
if reinstatement_event_is_actionable(event_type, object) {
|
||||
if let Err(status) =
|
||||
process_stripe_reinstatement_event(&state, payment_intent_id, event_type).await
|
||||
{
|
||||
return status.into_response();
|
||||
}
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
match reverse_license_for_payment_intent(&state, payment_intent_id, event_type).await {
|
||||
Ok(Some(user_id)) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id, event_type, "Processed Stripe payment reversal event"
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Stripe reversal event had no matching checkout reservation"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Failed to process Stripe payment reversal event: {err:?}"
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
|
||||
if let Some(refunded_amount_pence) = reversal_refund_amount_pence(event_type, object) {
|
||||
if let Err(status) = process_stripe_reversal_event(
|
||||
&state,
|
||||
payment_intent_id,
|
||||
event_type,
|
||||
refunded_amount_pence,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return status.into_response();
|
||||
}
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
info!(
|
||||
payment_intent_id,
|
||||
event_type, "Ignoring non-final Stripe payment adjustment event"
|
||||
);
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn signed_header(payload: &[u8], secret: &str, timestamp: i64) -> String {
|
||||
let signed_payload = format!("{timestamp}.{}", String::from_utf8_lossy(payload));
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(signed_payload.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
format!("t={timestamp},v1={signature}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_accepts_valid_header() {
|
||||
let payload = br#"{"id":"evt_123","type":"checkout.session.completed"}"#;
|
||||
let secret = "whsec_test_secret";
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let header = signed_header(payload, secret, now);
|
||||
|
||||
assert!(verify_signature(payload, &header, secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_rejects_tampered_payload() {
|
||||
let payload = br#"{"id":"evt_123","type":"checkout.session.completed"}"#;
|
||||
let secret = "whsec_test_secret";
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let header = signed_header(payload, secret, now);
|
||||
|
||||
assert!(!verify_signature(
|
||||
br#"{"id":"evt_123","type":"invoice.paid"}"#,
|
||||
&header,
|
||||
secret
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_rejects_stale_header() {
|
||||
let payload = br#"{"id":"evt_123","type":"checkout.session.completed"}"#;
|
||||
let secret = "whsec_test_secret";
|
||||
let header = signed_header(payload, secret, 1);
|
||||
|
||||
assert!(!verify_signature(payload, &header, secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversal_refund_amount_only_accepts_full_charge_refunds() {
|
||||
let partial = serde_json::json!({
|
||||
"amount": 1000,
|
||||
"amount_refunded": 500,
|
||||
"refunded": false,
|
||||
});
|
||||
let full = serde_json::json!({
|
||||
"amount": 1000,
|
||||
"amount_refunded": 1000,
|
||||
"refunded": true,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reversal_refund_amount_pence("charge.refunded", &partial),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
reversal_refund_amount_pence("charge.refunded", &full),
|
||||
Some(Some(1000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversal_refund_amount_requires_succeeded_refund() {
|
||||
let pending = serde_json::json!({
|
||||
"amount": 1000,
|
||||
"status": "pending",
|
||||
});
|
||||
let succeeded = serde_json::json!({
|
||||
"amount": 1000,
|
||||
"status": "succeeded",
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reversal_refund_amount_pence("refund.created", &pending),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
reversal_refund_amount_pence("refund.created", &succeeded),
|
||||
Some(Some(1000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_intent_id_accepts_expanded_objects() {
|
||||
let object = serde_json::json!({
|
||||
"payment_intent": { "id": "pi_123" }
|
||||
});
|
||||
|
||||
assert_eq!(payment_intent_id_from_object(&object), Some("pi_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispute_closed_routes_lost_and_won_differently() {
|
||||
let lost = serde_json::json!({ "status": "lost" });
|
||||
let won = serde_json::json!({ "status": "won" });
|
||||
|
||||
assert_eq!(
|
||||
reversal_refund_amount_pence("charge.dispute.closed", &lost),
|
||||
Some(None)
|
||||
);
|
||||
assert!(!reinstatement_event_is_actionable(
|
||||
"charge.dispute.closed",
|
||||
&lost
|
||||
));
|
||||
assert_eq!(
|
||||
reversal_refund_amount_pence("charge.dispute.closed", &won),
|
||||
None
|
||||
);
|
||||
assert!(reinstatement_event_is_actionable(
|
||||
"charge.dispute.closed",
|
||||
&won
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue