ok
This commit is contained in:
parent
c2070693fb
commit
909e241907
55 changed files with 594 additions and 223 deletions
|
|
@ -104,7 +104,7 @@ impl Aggregator {
|
|||
}
|
||||
|
||||
/// Add a row using row-major feature_data layout (quantized u16).
|
||||
/// feature_data[row * num_features + feat_idx] — all features for one row
|
||||
/// feature_data[row * num_features + feat_idx]: all features for one row
|
||||
/// are contiguous, so this reads a single cache line per ~16 features.
|
||||
#[inline]
|
||||
pub fn add_row(
|
||||
|
|
@ -131,7 +131,7 @@ impl Aggregator {
|
|||
}
|
||||
}
|
||||
// Enum distribution: single branch per row (not per feature).
|
||||
// Uses raw u16 directly — enum features are stored as u16 indices.
|
||||
// Uses raw u16 directly: enum features are stored as u16 indices.
|
||||
if let Some(ref mut ed) = self.enum_dist {
|
||||
let raw = row_slice[ed.feat_idx];
|
||||
if raw != NAN_U16 {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use super::{
|
|||
ensure_success_ref, is_safe_stripe_session_id, CHECKOUT_CURRENCY, REFERRAL_DISCOUNT_PERCENT,
|
||||
};
|
||||
|
||||
const CHECKOUT_PRODUCT_NAME: &str = "Perfect Postcodes Lifetime License";
|
||||
const CHECKOUT_PRODUCT_NAME: &str = "Perfect Postcode: Lifetime Licence";
|
||||
|
||||
/// Fetch a Stripe coupon and ensure its `percent_off` matches the expected
|
||||
/// referral discount AND that it has no `amount_off` override. This blocks a
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ impl MockPocketBase {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Number of PATCHes that set the user's subscription to "licensed" —
|
||||
/// Number of PATCHes that set the user's subscription to "licensed",
|
||||
/// i.e. how many times a license was granted.
|
||||
fn license_grant_count(&self, user_id: &str) -> usize {
|
||||
let path = format!("/api/collections/users/records/{user_id}");
|
||||
|
|
@ -414,7 +414,9 @@ async fn redeem_invite(env: &TestEnv, user: PocketBaseUser, code: &str) -> Respo
|
|||
post_redeem_invite(
|
||||
State(env.shared.clone()),
|
||||
Extension(OptionalUser(Some(user))),
|
||||
Json(serde_json::from_value(json!({ "code": code })).expect("redeem request deserializes")),
|
||||
Ok(Json(
|
||||
serde_json::from_value(json!({ "code": code })).expect("redeem request deserializes"),
|
||||
)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
//! These averages are precomputed by the data pipeline
|
||||
//! (`pipeline/transform/area_crime_averages.py`) and loaded here from a side
|
||||
//! parquet. Crime figures are constant within a postcode, so the pipeline
|
||||
//! property-weights each postcode's value by its property count — keeping these
|
||||
//! property-weights each postcode's value by its property count, keeping these
|
||||
//! averages on the same property-weighted basis as the per-selection mean, so the
|
||||
//! four numbers (this area / sector / outcode / nation) are directly comparable.
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ pub struct AreaCrimeAverages {
|
|||
pub crime_types: Vec<String>,
|
||||
/// National mean headline count per crime type (index-aligned with
|
||||
/// `crime_types`). An EXACT property-weighted mean over every postcode, so it
|
||||
/// shares a basis with `by_outcode`/`by_sector` and the per-selection mean —
|
||||
/// shares a basis with `by_outcode`/`by_sector` and the per-selection mean,
|
||||
/// unlike the histogram-bin national average, which is biased upward for the
|
||||
/// right-skewed crime counts. `NaN` where no postcode has data.
|
||||
pub national: Vec<f32>,
|
||||
|
|
@ -53,7 +53,7 @@ pub struct AreaCrimeAverages {
|
|||
}
|
||||
|
||||
impl AreaCrimeAverages {
|
||||
/// Empty table — used only by the test-only `AppState` builder (the real
|
||||
/// Empty table, used only by the test-only `AppState` builder (the real
|
||||
/// server always loads the precomputed parquet).
|
||||
#[cfg(test)]
|
||||
pub fn empty() -> Self {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Per-postcode per-crime-type per-year crime counts, loaded from a side
|
||||
//! parquet and used by the right pane to plot crime-over-time. Filtering is not
|
||||
//! supported — this data is display-only.
|
||||
//! supported: this data is display-only.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ pub const BY_YEAR_SUFFIX: &str = " (by year)";
|
|||
/// months}]` of the years the postcode's home force published enough months.
|
||||
/// police.uk has multi-year publication gaps for whole forces (e.g. Greater
|
||||
/// Manchester 2019-07 onwards), and a missing year is *no data*, not zero
|
||||
/// crime — consumers must exclude uncovered (postcode, year)s instead of
|
||||
/// crime. Consumers must exclude uncovered (postcode, year)s instead of
|
||||
/// charting them as zeros.
|
||||
pub const COVERAGE_COLUMN: &str = "covered_years";
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ pub struct CrimeByYearData {
|
|||
pub series_by_postcode: FxHashMap<String, Vec<PostcodeCrimeSeries>>,
|
||||
/// Postcode → years its police force actually published data for (from
|
||||
/// the `covered_years` column). An EMPTY vec means the postcode's crime
|
||||
/// picture is unknown (force gap / unusable geometry) — it must not count
|
||||
/// picture is unknown (force gap / unusable geometry). It must not count
|
||||
/// toward any year. A postcode ABSENT from this map (legacy parquet
|
||||
/// without the column) is treated as covered for every year.
|
||||
pub covered_years_by_postcode: FxHashMap<String, Vec<i32>>,
|
||||
|
|
@ -181,7 +181,7 @@ impl CrimeByYearData {
|
|||
|
||||
// Force-coverage calendar (optional column: legacy parquets predate it;
|
||||
// their postcodes are treated as fully covered). A row with an empty
|
||||
// list is meaningful — zero covered years — so it IS inserted.
|
||||
// list is meaningful (zero covered years), so it IS inserted.
|
||||
let mut covered_years_by_postcode: FxHashMap<String, Vec<i32>> = FxHashMap::default();
|
||||
if let Ok(col) = df.column(COVERAGE_COLUMN) {
|
||||
let list_ca = col
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Individual police.uk crime records (last 7 years) backing the right pane's
|
||||
//! "individual crimes" list and the `/api/crime-records` endpoint.
|
||||
//!
|
||||
//! This table is enormous — ~500M rows, because each incident is replicated to
|
||||
//! This table is enormous, ~500M rows, because each incident is replicated to
|
||||
//! every postcode whose buffer covers it (see [`gather`](CrimeRecords::gather)),
|
||||
//! so it is NOT held as a `Vec<struct>`: each field is a flat columnar
|
||||
//! [`SpillVec`] (mmap-backed and kernel-reclaimable when `--spill-dir` is set),
|
||||
|
|
@ -112,7 +112,7 @@ impl CrimeRecords {
|
|||
|
||||
/// Record indices across `postcodes`, newest first, optionally restricted to
|
||||
/// months `>= since_month`. These are exactly the incidents counted for the
|
||||
/// selected postcodes — for a single postcode that is its precise incident
|
||||
/// selected postcodes. For a single postcode that is its precise incident
|
||||
/// list; for a multi-postcode selection a boundary incident counted for
|
||||
/// several postcodes appears once per postcode, matching the count. We do not
|
||||
/// de-duplicate because police.uk snaps many genuinely distinct incidents
|
||||
|
|
@ -173,7 +173,7 @@ impl CrimeRecords {
|
|||
}
|
||||
|
||||
// Columns that, when spilling, are written straight into mmap-backed files
|
||||
// as we stream — so the ~9GB of columnar data never lands on the heap.
|
||||
// as we stream, so the ~9GB of columnar data never lands on the heap.
|
||||
let mut month = SpillVecBuilder::<u32>::with_len(n, spill_dir, "crime_month")?;
|
||||
let mut ctype = SpillVecBuilder::<u8>::with_len(n, spill_dir, "crime_ctype")?;
|
||||
let mut outcome = SpillVecBuilder::<u8>::with_len(n, spill_dir, "crime_outcome")?;
|
||||
|
|
@ -439,7 +439,7 @@ mod tests {
|
|||
}
|
||||
|
||||
/// The CSR per-postcode index and the column builders must compose correctly
|
||||
/// across streaming chunk boundaries — including a postcode run split between
|
||||
/// across streaming chunk boundaries, including a postcode run split between
|
||||
/// two chunks. Forces `chunk_rows = 2` over the 4-row fixture so AA1 1AA's
|
||||
/// three records straddle the boundary (rows 0,1 in chunk 0; row 2 in chunk 1)
|
||||
/// and is exercised both heap-backed (no spill) and mmap-backed (spill).
|
||||
|
|
@ -542,7 +542,7 @@ mod tests {
|
|||
// ceiling still proves we never materialise the whole file.
|
||||
assert!(
|
||||
hwm_after - hwm_before < 20_480.0,
|
||||
"peak RSS grew by {:.0} MiB during load — streaming/spill not bounding memory",
|
||||
"peak RSS grew by {:.0} MiB during load: streaming/spill not bounding memory",
|
||||
hwm_after - hwm_before
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const GRID_CELL_SIZE: f32 = 0.01;
|
|||
/// A single planned/pipeline development site (one brownfield-register entry or
|
||||
/// one Homes England land-disposal site). These are *sites*, not properties:
|
||||
/// they carry a coordinate, an estimate of the number of new dwellings, and the
|
||||
/// planning status — the forward-looking "where new homes are coming" signal.
|
||||
/// planning status, the forward-looking "where new homes are coming" signal.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DevelopmentSite {
|
||||
pub lat: f32,
|
||||
|
|
|
|||
|
|
@ -772,7 +772,7 @@ mod tests {
|
|||
#[test]
|
||||
fn normalize_search_text_elides_every_apostrophe_variant() {
|
||||
// Whatever glyph the keyboard / autocorrect / paste produced for the apostrophe, the
|
||||
// normalized form must be identical — otherwise "King's Cross" tokenizes as `king s cross`
|
||||
// normalized form must be identical. Otherwise "King's Cross" tokenizes as `king s cross`
|
||||
// and matching breaks. See is_apostrophe for the full set.
|
||||
let expected = "kings cross";
|
||||
for q in [
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ fn build_school_meta(
|
|||
) -> anyhow::Result<(Vec<u32>, Vec<SchoolMetadata>)> {
|
||||
let phase = extract_optional_str_col(df, "school_phase")?;
|
||||
if phase.is_none() {
|
||||
// POI parquet predates the school metadata extension — record an empty
|
||||
// POI parquet predates the school metadata extension. Record an empty
|
||||
// table and a sentinel-filled index, so callers transparently see None.
|
||||
return Ok((vec![u32::MAX; row_count], Vec::new()));
|
||||
}
|
||||
|
|
@ -404,7 +404,7 @@ fn build_school_meta(
|
|||
let type_group_val = fetch_str(&type_group, row);
|
||||
let type_val = fetch_str(&r#type, row);
|
||||
// type_group is present for every GIAS row, so use it as the sentinel
|
||||
// for "this POI is a school" — matches the pipeline guarantee.
|
||||
// for "this POI is a school", matching the pipeline guarantee.
|
||||
if type_group_val.is_none() && type_val.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub struct PostcodePopulation {
|
|||
}
|
||||
|
||||
impl PostcodePopulation {
|
||||
/// Empty table — used in tests and when no --population-path is supplied.
|
||||
/// Empty table, used in tests and when no --population-path is supplied.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
by_postcode: FxHashMap::default(),
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ fn validate_no_england_rows_missing_coordinates(
|
|||
impl PropertyData {
|
||||
/// Load the property data. When `spill` is `Some(dir)`, the large flat arrays
|
||||
/// (the feature matrix and the address-search index) are written to anonymous
|
||||
/// files in `dir` and memory-mapped read-only instead of held on the heap —
|
||||
/// files in `dir` and memory-mapped read-only instead of held on the heap:
|
||||
/// the `--spill-dir` dev flag, which lets a low-memory box page them from disk.
|
||||
pub fn load(
|
||||
properties_path: &Path,
|
||||
|
|
@ -221,7 +221,7 @@ impl PropertyData {
|
|||
let mut poi_metrics = PostcodePoiMetrics::from_postcode_df(&postcode_df, poi_metric_names)?;
|
||||
|
||||
// Load properties.parquet and join with postcode data lazily so the
|
||||
// wide combined frame is never fully materialized — projection is
|
||||
// wide combined frame is never fully materialized: projection is
|
||||
// pushed down into the join, keeping peak memory bounded.
|
||||
tracing::info!("Loading properties from {:?}", properties_path);
|
||||
let properties_path = PlRefPath::try_from_path(properties_path)
|
||||
|
|
@ -424,7 +424,7 @@ impl PropertyData {
|
|||
|
||||
// Compute quantization parameters from feature stats (numeric features).
|
||||
// For features with Fixed bounds, use those bounds so the full configured range
|
||||
// is representable — the histogram refinement can narrow min/max to exclude
|
||||
// is representable. The histogram refinement can narrow min/max to exclude
|
||||
// "outliers" that are actually valid data (e.g. ethnicity percentages).
|
||||
// For Percentile-bounded features, use the (possibly refined) histogram range
|
||||
// so extreme outliers don't destroy precision for the main distribution.
|
||||
|
|
@ -665,6 +665,9 @@ impl PropertyData {
|
|||
let prices = structs
|
||||
.field_by_name("price")
|
||||
.context("Missing 'price' field in historical_prices struct")?;
|
||||
// Per-sale new-build flag. Optional: older parquet predates
|
||||
// it, so a missing field degrades gracefully to `false`.
|
||||
let is_new_flags = structs.field_by_name("is_new").ok();
|
||||
|
||||
let mut row_prices = Vec::new();
|
||||
for idx in 0..inner.len() {
|
||||
|
|
@ -680,10 +683,16 @@ impl PropertyData {
|
|||
let AnyValue::Int64(price_i64) = price else {
|
||||
bail!("historical_prices.price is not Int64 at row {old_row}, got {price:?}");
|
||||
};
|
||||
let is_new = is_new_flags
|
||||
.as_ref()
|
||||
.and_then(|flags| flags.get(idx).ok())
|
||||
.map(|value| matches!(value, AnyValue::Boolean(true)))
|
||||
.unwrap_or(false);
|
||||
row_prices.push(HistoricalPrice {
|
||||
year: year_i32,
|
||||
month: month_u8,
|
||||
price: price_i64,
|
||||
is_new,
|
||||
});
|
||||
}
|
||||
if !row_prices.is_empty() {
|
||||
|
|
@ -789,8 +798,8 @@ impl PropertyData {
|
|||
.context("Required numeric column 'Last known price' not configured")?;
|
||||
|
||||
// Build contiguous address buffer and address search index (permuted).
|
||||
// Each row's posting lists cover BOTH address spellings we hold — the
|
||||
// price-paid form and the EPC form — so a property is findable by either;
|
||||
// Each row's posting lists cover BOTH address spellings we hold (the
|
||||
// price-paid form and the EPC form), so a property is findable by either;
|
||||
// the display address prefers the price-paid form, falling back to the EPC
|
||||
// form for never-sold (EPC-only) dwellings whose price-paid form is null.
|
||||
tracing::info!("Building interned strings");
|
||||
|
|
@ -1070,7 +1079,7 @@ impl PropertyData {
|
|||
|
||||
// Spill the remaining large flat arrays (the address-search index) to disk
|
||||
// when configured. The posting-list hashmaps and interners stay on the heap
|
||||
// — they aren't flat arrays, and they're a small fraction of the footprint.
|
||||
// (they aren't flat arrays, and they're a small fraction of the footprint).
|
||||
let address_buffer =
|
||||
SpillVec::maybe_spill(address_buffer.into_bytes(), spill, "address_buffer")?;
|
||||
let address_offsets = SpillVec::maybe_spill(address_offsets, spill, "address_offsets")?;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ pub struct HistoricalPrice {
|
|||
pub year: i32,
|
||||
pub month: u8,
|
||||
pub price: i64,
|
||||
/// Whether this specific sale was a new-build transfer (PPD `old_new == "Y"`).
|
||||
/// Defaults to `false` for older parquet that predates the per-sale flag.
|
||||
pub is_new: bool,
|
||||
}
|
||||
|
||||
/// A point on the property's tenure timeline: the year a certificate first
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ use crate::consts::HISTOGRAM_BINS;
|
|||
use crate::features::Bounds;
|
||||
|
||||
/// Histogram with outlier buckets at the edges.
|
||||
/// - Bin 0: [min, p1) — low outliers
|
||||
/// - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided
|
||||
/// - Bin n-1: [p99, max] — high outliers
|
||||
/// - Bin 0: [min, p1): low outliers
|
||||
/// - Bins 1 to n-2: [p1, p99): main distribution, evenly divided
|
||||
/// - Bin n-1: [p99, max]: high outliers
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Histogram {
|
||||
pub min: f32,
|
||||
|
|
@ -218,9 +218,9 @@ pub fn compute_feature_stats(vals: &[f32], bounds: &Bounds, integer_bins: bool)
|
|||
};
|
||||
|
||||
// Build final histogram with outlier bins at edges:
|
||||
// - Bin 0: [min, p1) — low outliers
|
||||
// - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided
|
||||
// - Bin n-1: [p99, max] — high outliers
|
||||
// - Bin 0: [min, p1): low outliers
|
||||
// - Bins 1 to n-2: [p1, p99): main distribution, evenly divided
|
||||
// - Bin n-1: [p99, max]: high outliers
|
||||
let mut counts = vec![0u64; num_bins];
|
||||
let middle_bins = num_bins.saturating_sub(2);
|
||||
let middle_width = if middle_bins > 0 && p99 > p1 {
|
||||
|
|
|
|||
|
|
@ -713,19 +713,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
raw: false,
|
||||
absolute: false,
|
||||
}),
|
||||
// EPC-derived council-housing footprint for the neighbourhood (LSOA).
|
||||
// Aggregated in the pipeline from the per-property "Former council
|
||||
// house" flag and the latest certificate tenure. The denominator is
|
||||
// ALL dwellings in the LSOA (homes with no EPC count as not council),
|
||||
// so these are EPC-coverage-limited lower bounds, NOT a Census
|
||||
// household share. These two plus the Census "% Social rent" back the
|
||||
// frontend "Council housing" filter's Current / Ex / Both pill toggle.
|
||||
// EPC-derived council-housing footprint per postcode. Aggregated in
|
||||
// the pipeline from the per-property "Former council house" flag and
|
||||
// the latest certificate tenure. The denominator is ALL dwellings in
|
||||
// the postcode (homes with no EPC count as not council), so these are
|
||||
// coarse, EPC-coverage-limited lower bounds, NOT a Census household
|
||||
// share (and finer-grained than the LSOA-level Census "% Social rent",
|
||||
// which is the filter's "Current" pill). These two plus "% Social
|
||||
// rent" back the "Council housing" filter's Current / Ex / Both toggle.
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "% Council housing",
|
||||
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||
step: 1.0,
|
||||
description: "Share of nearby homes ever recorded as council or social housing",
|
||||
detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes whose Energy Performance Certificate history shows the property was council or social housing at some point, whether it is still social housing today or has since been sold (for example under Right to Buy). Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
|
||||
description: "Share of homes in the postcode ever recorded as council or social housing",
|
||||
detail: "Estimated from EPC tenure records within the postcode: the share of its homes whose Energy Performance Certificate history shows the property was council or social housing at some point, whether it is still social housing today or has since been sold (for example under Right to Buy). Homes with no EPC are counted as not council, and a postcode holds few homes, so this is a coarse lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
|
||||
source: "epc",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
|
|
@ -736,8 +737,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
name: "% Ex-council",
|
||||
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||
step: 1.0,
|
||||
description: "Share of nearby homes that were council housing but are no longer",
|
||||
detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes once recorded as council or social housing whose most recent Energy Performance Certificate shows a different tenure, typically homes sold under Right to Buy. Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
|
||||
description: "Share of homes in the postcode that were council housing but are no longer",
|
||||
detail: "Estimated from EPC tenure records within the postcode: the share of its homes once recorded as council or social housing whose most recent Energy Performance Certificate shows a different tenure, typically homes sold under Right to Buy. Homes with no EPC are counted as not council, and a postcode holds few homes, so this is a coarse lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
|
||||
source: "epc",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ pub const DEFAULT_LANGUAGE: &str = "en";
|
|||
|
||||
const SUPPORTED_LANGUAGES: &[&str] = &["en", "fr", "de", "zh", "hi", "hu"];
|
||||
|
||||
/// The set of language codes the frontend can render, used e.g. to emit
|
||||
/// `hreflang` alternates for indexable pages.
|
||||
pub fn supported_languages() -> &'static [&'static str] {
|
||||
SUPPORTED_LANGUAGES
|
||||
}
|
||||
|
||||
pub fn supported_language(value: &str) -> Option<&'static str> {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
if value.is_empty() || value == "*" {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ mod checkout_sessions;
|
|||
mod consts;
|
||||
mod data;
|
||||
mod features;
|
||||
mod generated_data_pages;
|
||||
mod language;
|
||||
mod metrics;
|
||||
mod og_middleware;
|
||||
|
|
@ -124,6 +125,30 @@ async fn static_cache_headers(
|
|||
response
|
||||
}
|
||||
|
||||
/// Add baseline security headers to every response. These are deliberately the
|
||||
/// low-risk, broadly-compatible set: a Content-Security-Policy and
|
||||
/// Permissions-Policy are intentionally left out because this app loads many
|
||||
/// cross-origin resources (maplibre/deck.gl, Stripe, Google Street View,
|
||||
/// analytics, the error sink) and a mis-scoped policy would break the map or
|
||||
/// checkout. They should be added later as report-only first.
|
||||
async fn security_headers(
|
||||
request: axum::extract::Request,
|
||||
next: middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
headers
|
||||
.entry(header::X_CONTENT_TYPE_OPTIONS)
|
||||
.or_insert(HeaderValue::from_static("nosniff"));
|
||||
headers
|
||||
.entry(header::X_FRAME_OPTIONS)
|
||||
.or_insert(HeaderValue::from_static("SAMEORIGIN"));
|
||||
headers
|
||||
.entry(header::REFERRER_POLICY)
|
||||
.or_insert(HeaderValue::from_static("strict-origin-when-cross-origin"));
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resident_memory_kib() -> Option<u64> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
|
|
@ -998,7 +1023,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/pb/{*rest}",
|
||||
any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)),
|
||||
)
|
||||
// Tile routes use a different state type — kept as closures
|
||||
// Tile routes use a different state type, kept as closures
|
||||
.route(
|
||||
"/api/tiles/{z}/{x}/{y}",
|
||||
get(move |path| routes::get_tile(axum::extract::State(reader_tile.clone()), path))
|
||||
|
|
@ -1087,7 +1112,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
.route("/health", get(|| async { "ok" }))
|
||||
.route(
|
||||
"/metrics",
|
||||
get(move |connect_info| metrics::metrics_handler(metrics_handle.clone(), connect_info)),
|
||||
get(move |headers, connect_info| {
|
||||
metrics::metrics_handler(metrics_handle.clone(), headers, connect_info)
|
||||
}),
|
||||
)
|
||||
.with_state(shared.clone());
|
||||
|
||||
|
|
@ -1112,6 +1139,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
},
|
||||
))
|
||||
.layer(middleware::from_fn(static_cache_headers))
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
.layer(middleware::from_fn(capture_server_error_responses))
|
||||
.layer(cors)
|
||||
.layer(CompressionLayer::new().zstd(true).gzip(true))
|
||||
|
|
@ -1125,8 +1153,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
|
||||
// NOTE: we deliberately do NOT mlockall() here. Locking MCL_CURRENT|MCL_FUTURE
|
||||
// pinned the allocator's entire mapped heap — including jemalloc's freed/dirty
|
||||
// pages — resident and non-reclaimable, inflating RSS from the ~10GB working
|
||||
// pinned the allocator's entire mapped heap, including jemalloc's freed/dirty
|
||||
// pages, resident and non-reclaimable, inflating RSS from the ~10GB working
|
||||
// set to ~40GB and defeating the allocator's page-return entirely. The hot
|
||||
// working set stays resident naturally; freed pages are returned to the OS.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, Request};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use metrics::{counter, gauge, histogram};
|
||||
|
|
@ -119,7 +119,7 @@ fn normalize_path(path: &str) -> String {
|
|||
if path.starts_with("/assets/") {
|
||||
return "/assets/:file".to_string();
|
||||
}
|
||||
// Known application routes and API endpoints — keep as-is
|
||||
// Known application routes and API endpoints: keep as-is
|
||||
if path.starts_with("/api/")
|
||||
|| matches!(
|
||||
path,
|
||||
|
|
@ -147,11 +147,20 @@ fn normalize_path(path: &str) -> String {
|
|||
|
||||
/// Handler for the /metrics endpoint. Only accepts requests from peers on the
|
||||
/// same private network (loopback, RFC1918, or IPv6 unique/link-local).
|
||||
///
|
||||
/// The TCP peer is always the reverse proxy (a private container IP), so the
|
||||
/// real client IP is taken from the proxy-set `X-Real-IP` / `X-Forwarded-For`
|
||||
/// headers when present; a public client reaching this endpoint through the
|
||||
/// public domain is therefore rejected. Direct in-network connections (e.g. the
|
||||
/// Prometheus scraper hitting the container directly) carry no such header and
|
||||
/// fall back to the trusted TCP peer address.
|
||||
pub async fn metrics_handler(
|
||||
handle: PrometheusHandle,
|
||||
headers: HeaderMap,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
) -> Response {
|
||||
if !is_same_network(peer.ip()) {
|
||||
let client_ip = client_ip_from_headers(&headers).unwrap_or_else(|| peer.ip());
|
||||
if !is_same_network(client_ip) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +176,27 @@ pub async fn metrics_handler(
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve the real client IP from trusted reverse-proxy headers. nginx sets
|
||||
/// `X-Real-IP` to the post-`real_ip` client address and overwrites any
|
||||
/// client-supplied value, so when present it is authoritative; the left-most
|
||||
/// `X-Forwarded-For` hop is used as a fallback. Returns `None` when neither
|
||||
/// header is set, so a direct in-network connection uses its TCP peer address.
|
||||
fn client_ip_from_headers(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
if let Some(value) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
||||
if let Ok(ip) = value.trim().parse::<IpAddr>() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(first) = value.split(',').next() {
|
||||
if let Ok(ip) = first.trim().parse::<IpAddr>() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_same_network(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
|
||||
|
|
@ -216,3 +246,34 @@ pub fn record_data_stats(property_count: usize, poi_count: usize, postcode_count
|
|||
gauge!("data_pois_loaded").set(poi_count as f64);
|
||||
gauge!("data_postcodes_loaded").set(postcode_count as f64);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn x_real_ip_is_authoritative_and_public_ip_is_rejected() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", "203.0.113.7".parse().unwrap());
|
||||
let ip = client_ip_from_headers(&headers).expect("resolves from x-real-ip");
|
||||
assert_eq!(ip, "203.0.113.7".parse::<IpAddr>().unwrap());
|
||||
assert!(!is_same_network(ip), "a public client must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_proxy_headers_fall_back_to_peer() {
|
||||
// No X-Real-IP / X-Forwarded-For → caller uses the TCP peer (e.g. an
|
||||
// in-network Prometheus scraper hitting the container directly).
|
||||
assert_eq!(client_ip_from_headers(&HeaderMap::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_for_uses_first_hop() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "198.51.100.4, 10.0.0.1".parse().unwrap());
|
||||
assert_eq!(
|
||||
client_ip_from_headers(&headers),
|
||||
Some("198.51.100.4".parse::<IpAddr>().unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ use axum::middleware::Next;
|
|||
use axum::response::Response;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::language::{language_from_accept_language, query_string_with_language};
|
||||
use crate::language::{
|
||||
language_from_accept_language, query_string_with_language, supported_languages,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
const OG_PLACEHOLDER: &str =
|
||||
|
|
@ -133,6 +135,18 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
|||
description: "Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.",
|
||||
indexable: true,
|
||||
}),
|
||||
"/terms" => Some(SeoPage {
|
||||
canonical_path: "/terms",
|
||||
title: "Terms of Service | Perfect Postcode",
|
||||
description: "The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.",
|
||||
indexable: true,
|
||||
}),
|
||||
"/privacy" => Some(SeoPage {
|
||||
canonical_path: "/privacy",
|
||||
title: "Privacy Policy | Perfect Postcode",
|
||||
description: "How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.",
|
||||
indexable: true,
|
||||
}),
|
||||
"/dashboard" => Some(SeoPage {
|
||||
canonical_path: "/dashboard",
|
||||
title: "Perfect Postcode dashboard",
|
||||
|
|
@ -163,7 +177,14 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
|||
description: "Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.",
|
||||
indexable: false,
|
||||
}),
|
||||
_ => None,
|
||||
// Generated growth pages (cheaper-twin / value-index landing pages). Without this they
|
||||
// would 404 (should_return_404 keys off seo_page_for_path); see analysis/build_pages.py.
|
||||
_ => crate::generated_data_pages::data_page(path).map(|d| SeoPage {
|
||||
canonical_path: d.path,
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
indexable: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,12 +227,46 @@ fn not_found_response(public_url: &str, path: &str) -> Response {
|
|||
<title>Page not found - Perfect Postcode</title>
|
||||
<meta name="description" content="This Perfect Postcode page could not be found." />
|
||||
<link rel="canonical" href="{public_url_e}/" />
|
||||
<style>
|
||||
:root {{ color-scheme: dark; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0b1220;
|
||||
color: #e7ecf3;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}}
|
||||
main {{ max-width: 32rem; }}
|
||||
.brand {{ font-weight: 700; letter-spacing: .01em; color: #2dd4bf; font-size: 1.1rem; margin-bottom: 1.5rem; }}
|
||||
h1 {{ font-size: 2rem; margin: 0 0 .5rem; }}
|
||||
p {{ color: #9fb0c3; line-height: 1.6; margin: .5rem 0; }}
|
||||
code {{ color: #cbd5e1; background: rgba(148,163,184,.15); padding: .1rem .35rem; border-radius: .25rem; word-break: break-all; }}
|
||||
a.cta {{
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
padding: .65rem 1.25rem;
|
||||
border-radius: .5rem;
|
||||
background: #2dd4bf;
|
||||
color: #0b1220;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}}
|
||||
a.cta:hover {{ background: #5eead4; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand">Perfect Postcode</div>
|
||||
<h1>Page not found</h1>
|
||||
<p>The requested path was not found: {path_e}</p>
|
||||
<p><a href="{public_url_e}/">Go to Perfect Postcode</a></p>
|
||||
<p>We couldn't find <code>{path_e}</code>.</p>
|
||||
<p>It may have moved, or the link might be incomplete.</p>
|
||||
<a class="cta" href="{public_url_e}/">Back to Perfect Postcode</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#
|
||||
|
|
@ -234,7 +289,13 @@ fn route_seo_tags(
|
|||
) -> String {
|
||||
let path_e = escape_attr(path);
|
||||
let query_e = escape_attr(query_string);
|
||||
let screenshot_query_string = query_string_with_language(query_string, language);
|
||||
// Generated data pages have a clean URL with no query, but their OG card must frame the finding's
|
||||
// map view rather than the default whole-England map. Use the registry's screenshot query.
|
||||
let screenshot_query_base = match crate::generated_data_pages::data_page(trim_trailing_slash(path)) {
|
||||
Some(dp) if !dp.screenshot_query.is_empty() => dp.screenshot_query,
|
||||
_ => query_string,
|
||||
};
|
||||
let screenshot_query_string = query_string_with_language(screenshot_query_base, language);
|
||||
let screenshot_query_e = escape_attr(&screenshot_query_string);
|
||||
let public_url_e = escape_attr(public_url.trim_end_matches('/'));
|
||||
let canonical_path_e = escape_attr(page.canonical_path);
|
||||
|
|
@ -266,9 +327,26 @@ fn route_seo_tags(
|
|||
"noindex,follow"
|
||||
};
|
||||
|
||||
// Emit hreflang alternates for indexable pages so search engines can serve
|
||||
// the right localized variant (the SPA switches language via ?lang=).
|
||||
let hreflang = if page.indexable {
|
||||
let mut tags = String::new();
|
||||
for lang in supported_languages() {
|
||||
tags.push_str(&format!(
|
||||
"\n <link rel=\"alternate\" hreflang=\"{lang}\" href=\"{public_url_e}{canonical_path_e}?lang={lang}\" />"
|
||||
));
|
||||
}
|
||||
tags.push_str(&format!(
|
||||
"\n <link rel=\"alternate\" hreflang=\"x-default\" href=\"{public_url_e}{canonical_path_e}\" />"
|
||||
));
|
||||
tags
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<meta name="robots" content="{robots}" />
|
||||
<link rel="canonical" href="{canonical_url}" />
|
||||
<link rel="canonical" href="{canonical_url}" />{hreflang}
|
||||
<meta property="og:title" content="{title_e}" />
|
||||
<meta property="og:description" content="{description_e}" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
|
@ -406,3 +484,48 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
parts.headers.remove(header::CONTENT_LENGTH);
|
||||
Response::from_parts(parts, Body::from(html))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legal_pages_resolve_and_are_not_404() {
|
||||
for path in ["/terms", "/privacy", "/terms/", "/privacy/"] {
|
||||
assert!(
|
||||
seo_page_for_path(path).is_some(),
|
||||
"{path} should resolve to an SEO page (legal pages must render, not 404)"
|
||||
);
|
||||
assert!(
|
||||
!should_return_404(path),
|
||||
"{path} must not return a server 404"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_paths_still_404() {
|
||||
assert!(should_return_404("/definitely-not-a-real-page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexable_pages_emit_hreflang() {
|
||||
let page = seo_page_for_path("/terms").expect("terms page");
|
||||
let tags = route_seo_tags(&page, "/terms", "", "https://perfect-postcode.co.uk", "en");
|
||||
assert!(tags.contains(r#"hreflang="x-default""#));
|
||||
assert!(tags.contains(r#"hreflang="de""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noindex_pages_omit_hreflang() {
|
||||
let page = seo_page_for_path("/dashboard").expect("dashboard page");
|
||||
let tags = route_seo_tags(
|
||||
&page,
|
||||
"/dashboard",
|
||||
"",
|
||||
"https://perfect-postcode.co.uk",
|
||||
"en",
|
||||
);
|
||||
assert!(!tags.contains("hreflang"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use rustc_hash::FxHashMap;
|
|||
|
||||
/// Parse an optional `?fields=` query param into feature indices for selective aggregation.
|
||||
/// Returns `None` if fields param is absent (all features included).
|
||||
/// Returns `Some(vec![])` if fields is present but empty (no features — count only).
|
||||
/// Returns `Some(vec![])` if fields is present but empty (no features, count only).
|
||||
/// Returns `Some(indices)` for named fields. Errors on unknown field names.
|
||||
pub fn parse_field_indices(
|
||||
fields: Option<&str>,
|
||||
|
|
|
|||
|
|
@ -610,7 +610,7 @@ mod tests {
|
|||
fn selectivity_sort_handles_saturating_sub() {
|
||||
// Even if a ParsedFilter is constructed directly with min_u16 > max_u16
|
||||
// (bypassing parse_filters validation), the sort must not wrap to a
|
||||
// huge u16 — saturating_sub clamps to 0.
|
||||
// huge u16: saturating_sub clamps to 0.
|
||||
let mut filters = [
|
||||
ParsedFilter {
|
||||
feat_idx: 0,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ impl SuperuserTokenCache {
|
|||
|
||||
/// Get a cached superuser token, or authenticate fresh if expired/missing.
|
||||
pub async fn get_superuser_token(state: &AppState) -> anyhow::Result<String> {
|
||||
// Check cache first (read lock — cheap, non-blocking for other readers)
|
||||
// Check cache first (read lock: cheap, non-blocking for other readers)
|
||||
{
|
||||
let cached = state.superuser_token_cache.token.read();
|
||||
if let Some((token, created)) = cached.as_ref() {
|
||||
|
|
@ -38,7 +38,7 @@ pub async fn get_superuser_token(state: &AppState) -> anyhow::Result<String> {
|
|||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired — fetch a fresh token
|
||||
// Cache miss or expired: fetch a fresh token
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = auth_superuser(
|
||||
&state.http_client,
|
||||
|
|
@ -861,7 +861,7 @@ async fn ensure_saved_searches_rules(
|
|||
}
|
||||
|
||||
/// Ensure the `saved_searches` collection has a `screenshot` file field.
|
||||
/// This field was added after the initial collection schema — existing deployments
|
||||
/// This field was added after the initial collection schema. Existing deployments
|
||||
/// need it patched in so the frontend can attach screenshot JPEGs to saved searches.
|
||||
async fn ensure_screenshot_field(
|
||||
client: &Client,
|
||||
|
|
@ -971,7 +971,7 @@ async fn ensure_notes_field(
|
|||
}
|
||||
|
||||
/// Ensure a collection has `created` and `updated` autodate fields.
|
||||
/// PocketBase 0.23+ no longer adds these automatically — they must be explicit.
|
||||
/// PocketBase 0.23+ no longer adds these automatically: they must be explicit.
|
||||
async fn ensure_autodate_fields(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
|
|
@ -1419,7 +1419,7 @@ pub async fn ensure_oauth_providers(
|
|||
{
|
||||
Some(idx) => &mut providers[idx],
|
||||
None => {
|
||||
info!("Google provider not found — adding it");
|
||||
info!("Google provider not found, adding it");
|
||||
providers.push(serde_json::json!({"name": "google"}));
|
||||
providers.last_mut().expect("just pushed")
|
||||
}
|
||||
|
|
@ -1504,7 +1504,7 @@ async fn poll_pocketbase_counts(state: &AppState) {
|
|||
}
|
||||
|
||||
/// Insert a record into the `location_logs` collection.
|
||||
/// Best-effort — logs warnings on failure but does not propagate errors.
|
||||
/// Best-effort: logs warnings on failure but does not propagate errors.
|
||||
pub async fn log_user_location(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
|
|
@ -1546,7 +1546,7 @@ pub async fn log_user_location(
|
|||
}
|
||||
|
||||
/// Insert a record into the `ai_query_logs` collection.
|
||||
/// Best-effort — logs warnings on failure but does not propagate errors.
|
||||
/// Best-effort: logs warnings on failure but does not propagate errors.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_ai_query(
|
||||
state: &AppState,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ use crate::state::AppState;
|
|||
|
||||
/// Extract the client IP, preferring reverse-proxy/CDN headers (we sit behind one
|
||||
/// in production) and falling back to the socket peer. Client-supplied and
|
||||
/// spoofable — only used as a coarse rate-limit key for anonymous visitors.
|
||||
/// spoofable: only used as a coarse rate-limit key for anonymous visitors.
|
||||
fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>) -> Option<IpAddr> {
|
||||
let from_header = |name: &str, take_first: bool| -> Option<IpAddr> {
|
||||
let raw = headers.get(name)?.to_str().ok()?;
|
||||
|
|
@ -72,7 +72,7 @@ impl DemoRateLimiter {
|
|||
// Bound memory: when the table grows large, drop keys with no recent hits.
|
||||
// If even that can't get us back under the cap (e.g. a spoofed-IP flood
|
||||
// minting a fresh key per request), clear the table outright. That resets
|
||||
// everyone's window — acceptable under attack — and keeps both the size and
|
||||
// everyone's window (acceptable under attack) and keeps both the size and
|
||||
// the cost of this scan bounded (it then won't re-run for ~MAX_KEYS inserts,
|
||||
// instead of scanning O(n) on every request once full).
|
||||
if map.len() > DEMO_RATE_LIMIT_MAX_KEYS {
|
||||
|
|
@ -197,7 +197,7 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response {
|
|||
if let Some(query) = req.uri().query() {
|
||||
if filter_count(query) > filter_limit {
|
||||
let message = if user.is_some() {
|
||||
format!("Free accounts are limited to {filter_limit} filters at a time — upgrade for unlimited")
|
||||
format!("Free accounts are limited to {filter_limit} filters at a time. Upgrade for unlimited")
|
||||
} else {
|
||||
format!("Create a free account for up to {REGISTERED_MAX_FILTERS} filters (currently limited to {filter_limit})")
|
||||
};
|
||||
|
|
@ -228,7 +228,7 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response {
|
|||
StatusCode::TOO_MANY_REQUESTS,
|
||||
axum::Json(json!({
|
||||
"error": "rate_limited",
|
||||
"message": "Too many requests — slow down, or sign in for full access",
|
||||
"message": "Too many requests. Slow down, or sign in for full access",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
|
|
|
|||
|
|
@ -241,11 +241,11 @@ pub async fn post_ai_filters(
|
|||
}]
|
||||
}));
|
||||
|
||||
// Continue the loop — model will process the results
|
||||
// Continue the loop: model will process the results
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model returned text — extract and parse as JSON
|
||||
// Model returned text: extract and parse as JSON
|
||||
let text = parts
|
||||
.iter()
|
||||
.find_map(|part| part.get("text").and_then(|t| t.as_str()))
|
||||
|
|
@ -330,7 +330,7 @@ pub async fn post_ai_filters(
|
|||
let total_rows = state.data.lat.len();
|
||||
info!(
|
||||
attempt = refinement_attempts,
|
||||
"0 matches out of {total_rows} — asking AI to relax filters"
|
||||
"0 matches out of {total_rows}: asking AI to relax filters"
|
||||
);
|
||||
|
||||
if refinement_attempts > MAX_REFINEMENTS {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ fn backend_filter_name(name: &str) -> Option<String> {
|
|||
"Ethnicities:",
|
||||
"Qualifications:",
|
||||
"Tenure:",
|
||||
"Council housing:",
|
||||
"Amenity distance:",
|
||||
"Closest transport option:",
|
||||
"Amenities within 2km:",
|
||||
|
|
@ -241,7 +242,7 @@ pub(super) fn validate_and_convert(raw: &Value, features: &FeaturesResponse) ->
|
|||
}
|
||||
}
|
||||
|
||||
// Process numeric filters — each sets one bound (min or max).
|
||||
// Process numeric filters: each sets one bound (min or max).
|
||||
// The unset side uses the true data min/max (from histogram), not
|
||||
// the slider bounds (percentile-based), so a "max" filter for crime
|
||||
// produces [0, value] rather than [2nd-percentile, value].
|
||||
|
|
@ -371,6 +372,16 @@ mod tests {
|
|||
canonical_filter_name("Tenure:%25%20Owner%20occupied:0"),
|
||||
"% Owner occupied"
|
||||
);
|
||||
// The "Council housing" pill toggle re-points to the Census social-rent
|
||||
// column for "current" and to these EPC columns for "ex" / "both".
|
||||
assert_eq!(
|
||||
canonical_filter_name("Council housing:%25%20Ex-council:0"),
|
||||
"% Ex-council"
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_filter_name("Council housing:%25%20Council%20housing:1"),
|
||||
"% Council housing"
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_filter_name("Serious crime:Serious%20crime%20(%2Fyr%2C%207y):0"),
|
||||
"Serious crime (/yr, 7y)"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! `GET /api/crime-records` — the individual police.uk crimes (last 7 years)
|
||||
//! `GET /api/crime-records`: the individual police.uk crimes (last 7 years)
|
||||
//! behind a selected hexagon or postcode, paginated. Display-only and
|
||||
//! independent of the property filters, like the population figure: the records
|
||||
//! are an attribute of the area, not of the filter-matching subset.
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ pub struct CrimeYearStats {
|
|||
pub struct CrimeAreaAverage {
|
||||
/// Full crime-feature name (e.g. "Burglary (/yr, 7y)").
|
||||
pub name: String,
|
||||
/// Exact national mean count — the frontend prefers this over the
|
||||
/// Exact national mean count. The frontend prefers this over the
|
||||
/// histogram-bin national average for crime so all four numbers in the row
|
||||
/// share one estimator.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -163,7 +163,7 @@ pub struct HexagonStatsResponse {
|
|||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub crime_area_averages: Vec<CrimeAreaAverage>,
|
||||
/// Total individual crime records (last 7 years) across the distinct
|
||||
/// postcodes in this selection — the count behind the "individual crimes"
|
||||
/// postcodes in this selection: the count behind the "individual crimes"
|
||||
/// list. Filter-independent, like `population`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crime_total_records: Option<u32>,
|
||||
|
|
@ -627,7 +627,7 @@ pub async fn get_hexagon_stats(
|
|||
});
|
||||
row.map(|row| state.data.postcode(row).to_string())
|
||||
} else {
|
||||
// No journey destination requested — use geographic center
|
||||
// No journey destination requested. Use geographic center
|
||||
let closest_row = matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
|
|
@ -693,7 +693,7 @@ pub async fn get_hexagon_stats(
|
|||
);
|
||||
|
||||
// Distinct postcodes covered by the hexagon, taken over `area_rows` (all
|
||||
// properties in the cell), not the filter-matching subset — population and
|
||||
// properties in the cell), not the filter-matching subset: population and
|
||||
// the crime-records count are attributes of the area, independent of the
|
||||
// active filters (like the council-house count).
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ pub struct HexagonParams {
|
|||
/// Build feature maps from aggregated cell data, filtering to only cells whose
|
||||
/// center is within the query bounds (expanded by a resolution-dependent buffer).
|
||||
/// This is much cheaper than the previous approach of computing full cell boundaries
|
||||
/// (6 vertices per cell) — just 4 float comparisons per cell.
|
||||
/// (6 vertices per cell): just 4 float comparisons per cell.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_feature_maps(
|
||||
groups: &FxHashMap<u64, Aggregator>,
|
||||
|
|
@ -129,7 +129,7 @@ fn build_feature_maps(
|
|||
continue;
|
||||
};
|
||||
|
||||
// Center is already needed for lat/lon output — reuse for bounds check
|
||||
// Center is already needed for lat/lon output: reuse for bounds check
|
||||
let center: h3o::LatLng = cell.into();
|
||||
let lat = center.lat();
|
||||
let lng = center.lng();
|
||||
|
|
@ -341,7 +341,7 @@ pub async fn get_hexagons(
|
|||
.map(|_| FxHashMap::default())
|
||||
.collect();
|
||||
|
||||
// O(grid cells) count — no allocation. Used for parallel threshold decision.
|
||||
// O(grid cells) count: no allocation. Used for parallel threshold decision.
|
||||
let row_count = state.grid.count_in_bounds(south, west, north, east);
|
||||
let t_grid = t0.elapsed();
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ fn is_allowed_pb_path(path: &str) -> bool {
|
|||
) {
|
||||
return true;
|
||||
}
|
||||
// Prefix-allowed paths. The trailing slash is intentional — without it,
|
||||
// Prefix-allowed paths. The trailing slash is intentional. Without it,
|
||||
// `/api/collections/users` (the schema endpoint) would match.
|
||||
const ALLOWED_PREFIXES: &[&str] = &[
|
||||
"/api/collections/users/",
|
||||
|
|
@ -34,7 +34,7 @@ fn is_allowed_pb_path(path: &str) -> bool {
|
|||
.any(|prefix| path.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
|
||||
/// Dedicated HTTP client for proxying: does not follow redirects so 3xx
|
||||
/// responses are passed through to the browser (needed for OAuth flows).
|
||||
/// No client-wide timeout because SSE (Server-Sent Events) connections used
|
||||
/// by PocketBase realtime/OAuth2 are long-lived streams; non-realtime
|
||||
|
|
@ -132,7 +132,7 @@ pub async fn proxy_to_pocketbase(
|
|||
|
||||
// Stream the response body instead of buffering it entirely.
|
||||
// This is critical for SSE (Server-Sent Events) used by PocketBase's
|
||||
// realtime system and OAuth2 flow — buffering would hang forever
|
||||
// realtime system and OAuth2 flow: buffering would hang forever
|
||||
// since SSE responses never complete.
|
||||
let body = Body::from_stream(upstream.bytes_stream());
|
||||
response.body(body).unwrap_or_else(|err| {
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ fn is_postcode_fragmentish(token: &str) -> bool {
|
|||
|
||||
/// Peel a trailing geographic refinement (outcode, or outcode + sector digit) off the query.
|
||||
/// "camden nw1" → ("camden", Some("NW1")); the core matches the place, the refinement biases
|
||||
/// ranking and drives the outcode/postcode lists — instead of breaking the match entirely.
|
||||
/// ranking and drives the outcode/postcode lists, instead of breaking the match entirely.
|
||||
fn split_geographic_refinement(query: &str) -> (String, Option<String>) {
|
||||
let words: Vec<&str> = query.split_whitespace().collect();
|
||||
if words.len() < 2 {
|
||||
|
|
@ -320,7 +320,7 @@ pub async fn get_places(
|
|||
let bias_center = viewport.or_else(|| refinement_outcode.map(|idx| od.centroids[idx]));
|
||||
|
||||
// ---- Places: candidate rows from the inverted token index, then exact/prefix/token-AND
|
||||
// scoring — bounded by matched candidates, not the ~1M-row corpus. Fuzzy fallback uses the
|
||||
// scoring: bounded by matched candidates, not the ~1M-row corpus. Fuzzy fallback uses the
|
||||
// (small) trigram index over fuzzy-eligible rows only.
|
||||
let mut place_results: Vec<(f32, PlaceResult)> = Vec::new();
|
||||
let mut matched_place_idx: FxHashSet<usize> = FxHashSet::default();
|
||||
|
|
@ -400,7 +400,7 @@ pub async fn get_places(
|
|||
};
|
||||
if mode_filter.is_none() {
|
||||
if let Some(idx) = refinement_outcode {
|
||||
// A refinement ("camden nw1") resolves to exactly one outcode — no NW10/NW11 noise.
|
||||
// A refinement ("camden nw1") resolves to exactly one outcode: no NW10/NW11 noise.
|
||||
push_outcode(&mut place_results, idx, 980.0);
|
||||
} else if looks_like_postcode_prefix(&query) {
|
||||
// A bare postcode-prefix query ("e1") lists matching outcodes (e1, e10, e11, ...).
|
||||
|
|
@ -597,7 +597,7 @@ mod tests {
|
|||
// A reordered multi-word query still matches via token-AND.
|
||||
assert!(base("manchester piccadilly", "piccadilly manchester").is_some());
|
||||
// Pure infix substrings no longer match (candidates are token-based): "ford" must not
|
||||
// surface "Stratford" — that was the old population-dominated noise.
|
||||
// surface "Stratford". That was the old population-dominated noise.
|
||||
assert!(base("stratford", "ford").is_none());
|
||||
// Appended noise that matches nothing yields no match (the route strips postcodes first).
|
||||
assert!(base("camden", "camden zzzz").is_none());
|
||||
|
|
|
|||
|
|
@ -56,6 +56,21 @@ pub async fn get_postcodes(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
// Guard against unbounded GeoJSON payloads: postcodes are only ever requested
|
||||
// by the client at high zoom (POSTCODE_ZOOM_THRESHOLD = 13.5, viewport area
|
||||
// well under 0.1 deg²), so any request spanning a large area is a bug or an
|
||||
// abuse attempt. Without this cap a ~200-byte request could pull hundreds of
|
||||
// MB of full-resolution polygons for the whole country.
|
||||
const MAX_BOUNDS_AREA_DEG2: f64 = 1.0;
|
||||
let bounds_area = (north - south).abs() * (east - west).abs();
|
||||
if bounds_area > MAX_BOUNDS_AREA_DEG2 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Requested area is too large to load individual postcodes; zoom in.".to_string(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ fn is_allowed_param_key(key: &str) -> bool {
|
|||
| "ethnicity"
|
||||
| "qualification"
|
||||
| "tenure"
|
||||
| "council"
|
||||
| "amenityDistance"
|
||||
| "transportDistance"
|
||||
| "amenityCount2km"
|
||||
|
|
@ -504,6 +505,7 @@ mod tests {
|
|||
let query = "crimeSeverity=Serious%3A0%3A5\
|
||||
&qualification=Degree%3A20%3A80\
|
||||
&tenure=Owner%3A30%3A90\
|
||||
&council=Ex%3A10%3A90\
|
||||
&crimeType=burglary\
|
||||
&colorOpacity=60";
|
||||
let params = sanitized_query_params(query).unwrap();
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ pub struct AppState {
|
|||
/// served by the `/api/crime-records` endpoint and counted in stats.
|
||||
pub crime_records: Arc<CrimeRecords>,
|
||||
/// Per-unit-postcode usual-resident headcounts (Census 2021), shown in the
|
||||
/// right pane. Display-only — never filterable. Empty when no data is loaded.
|
||||
/// right pane. Display-only. Never filterable. Empty when no data is loaded.
|
||||
pub population: Arc<PostcodePopulation>,
|
||||
/// Precomputed per-outcode and per-postcode-sector average crime counts,
|
||||
/// shown in the right pane alongside the national average for each metric.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ pub fn normalize_postcode(raw: &str) -> String {
|
|||
|
||||
/// Outward code (outcode) of a normalized postcode: the part before the space.
|
||||
/// e.g. "E14 2DG" → Some("E14"). Returns `None` when the input has no space
|
||||
/// (already-short or malformed — `normalize_postcode` only inserts a space for
|
||||
/// (already-short or malformed: `normalize_postcode` only inserts a space for
|
||||
/// inputs of length ≥ 5).
|
||||
pub fn postcode_outcode(postcode: &str) -> Option<&str> {
|
||||
postcode.split_once(' ').map(|(outward, _)| outward)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue