This commit is contained in:
Andras Schmelczer 2026-05-17 10:16:30 +01:00
parent 47d89f6fad
commit 017902b8e6
82 changed files with 331466 additions and 54841 deletions

456
server-rs/Cargo.lock generated
View file

@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
@ -683,6 +692,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link 0.2.1",
]
[[package]]
name = "base16ct"
version = "0.1.1"
@ -772,6 +796,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "boxcar"
version = "0.2.14"
@ -1295,6 +1328,16 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
[[package]]
name = "debugid"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
"uuid",
]
[[package]]
name = "der"
version = "0.6.1"
@ -1338,6 +1381,16 @@ dependencies = [
"ctutils",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -1532,6 +1585,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "findshlibs"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64"
dependencies = [
"cc",
"lazy_static",
"libc",
"winapi",
]
[[package]]
name = "flate2"
version = "1.1.9"
@ -1773,6 +1838,12 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "glob"
version = "0.3.3"
@ -1947,6 +2018,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "http"
version = "0.2.12"
@ -2675,6 +2757,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "now"
version = "0.1.3"
@ -2738,6 +2832,36 @@ dependencies = [
"libm",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@ -2745,6 +2869,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
@ -2757,6 +2947,60 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags",
"block2",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "object"
version = "0.37.3"
@ -2831,6 +3075,22 @@ dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "os_info"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
dependencies = [
"android_system_properties",
"log",
"nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "outref"
version = "0.5.2"
@ -2926,6 +3186,26 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -3616,6 +3896,7 @@ dependencies = [
"reqwest 0.13.3",
"rust_xlsxwriter",
"rustc-hash",
"sentry",
"serde",
"serde_json",
"sha2 0.11.0",
@ -3935,6 +4216,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.14",
@ -4111,6 +4393,12 @@ dependencies = [
"zip",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "2.1.2"
@ -4346,6 +4634,130 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "sentry"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92d893ba7469d361a6958522fa440e4e2bc8bf4c5803cd1bf40b9af63f8f9a8"
dependencies = [
"cfg_aliases",
"httpdate",
"reqwest 0.12.28",
"rustls 0.23.40",
"sentry-backtrace",
"sentry-contexts",
"sentry-core",
"sentry-debug-images",
"sentry-panic",
"sentry-tower",
"sentry-tracing",
"tokio",
"ureq",
]
[[package]]
name = "sentry-backtrace"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f8784d0a27b5cd4b5f75769ffc84f0b7580e3c35e1af9cd83cb90b612d769cc"
dependencies = [
"backtrace",
"regex",
"sentry-core",
]
[[package]]
name = "sentry-contexts"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e5eb42f4cd4f9fdfec9e3b07b25a4c9769df83d218a7e846658984d5948ad3e"
dependencies = [
"hostname",
"libc",
"os_info",
"rustc_version",
"sentry-core",
"uname",
]
[[package]]
name = "sentry-core"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0b1e7ca40f965db239da279bf278d87b7407469b98835f27f0c8e59ed189b06"
dependencies = [
"rand 0.9.4",
"sentry-types",
"serde",
"serde_json",
"url",
]
[[package]]
name = "sentry-debug-images"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "002561e49ea3a9de316e2efadc40fae553921b8ff41448f02ea85fd135a778d6"
dependencies = [
"findshlibs",
"sentry-core",
]
[[package]]
name = "sentry-panic"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8906f8be87aea5ac7ef937323fb655d66607427f61007b99b7cb3504dc5a156c"
dependencies = [
"sentry-backtrace",
"sentry-core",
]
[[package]]
name = "sentry-tower"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56aebe376310840b49dad4cca55c7b32d9abdc14946cd071d4158ecb149b63a4"
dependencies = [
"axum",
"http 1.4.0",
"pin-project",
"sentry-core",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "sentry-tracing"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b07eefe04486316c57aba08ab53dd44753c25102d1d3fe05775cc93a13262d9"
dependencies = [
"bitflags",
"sentry-backtrace",
"sentry-core",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "sentry-types"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567711f01f86a842057e1fc17779eba33a336004227e1a1e7e6cc2599e22e259"
dependencies = [
"debugid",
"hex",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror",
"time",
"url",
"uuid",
]
[[package]]
name = "serde"
version = "1.0.228"
@ -5147,6 +5559,15 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uname"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8"
dependencies = [
"libc",
]
[[package]]
name = "unicase"
version = "2.9.0"
@ -5207,6 +5628,34 @@ version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64",
"log",
"percent-encoding",
"rustls 0.23.40",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64",
"http 1.4.0",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@ -5217,6 +5666,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
@ -5225,6 +5675,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View file

@ -33,6 +33,7 @@ sha2 = "0.11"
hex = "0.4"
tower = { version = "0.5", features = ["limit"] }
libc = "0.2"
sentry = { version = "0.46.0", default-features = false, features = ["backtrace", "contexts", "debug-images", "panic", "reqwest", "rustls", "tracing", "tower-http", "tower-axum-matched-path"] }
[lints.clippy]
min_ident_chars = "warn"

80
server-rs/src/bugsink.rs Normal file
View file

@ -0,0 +1,80 @@
use std::borrow::Cow;
use serde::Serialize;
#[derive(Clone, Debug)]
pub struct BackendConfig {
pub dsn: Option<String>,
pub environment: Option<String>,
pub release: Option<String>,
pub send_default_pii: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendConfig {
pub dsn: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release: Option<String>,
pub send_default_pii: bool,
}
pub fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name).ok().and_then(nonempty)
}
pub fn nonempty(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
pub fn default_release() -> String {
format!("{}@{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
pub fn init_backend(config: &BackendConfig) -> Option<sentry::ClientInitGuard> {
let dsn = config.dsn.clone().and_then(nonempty)?;
let dsn = match dsn.parse::<sentry::types::Dsn>() {
Ok(dsn) => dsn,
Err(err) => {
eprintln!("Ignoring invalid BUGSINK_DSN: {err}");
return None;
}
};
Some(sentry::init(sentry::ClientOptions {
dsn: Some(dsn),
environment: config
.environment
.clone()
.and_then(nonempty)
.map(Cow::Owned),
release: Some(Cow::Owned(
config
.release
.clone()
.and_then(nonempty)
.unwrap_or_else(default_release),
)),
send_default_pii: config.send_default_pii,
traces_sample_rate: 0.0,
..Default::default()
}))
}
pub fn frontend_config(
dsn: Option<String>,
environment: Option<String>,
release: Option<String>,
send_default_pii: bool,
) -> Option<FrontendConfig> {
dsn.and_then(nonempty).map(|dsn| FrontendConfig {
dsn,
environment: environment.and_then(nonempty),
release: release.and_then(nonempty),
send_default_pii,
})
}

View file

@ -21,6 +21,16 @@ pub struct PlaceData {
pub travel_destination: Vec<bool>,
}
pub(super) struct CityCandidate<'a> {
pub(super) name: &'a str,
pub(super) lat: f32,
pub(super) lon: f32,
}
const PARENT_CITY_MAX_DIST_SQ: f32 = 0.81;
const LONDON_DISPLAY_MAX_DEGREES: f32 = 30.0 / 111.0;
const LONDON_DISPLAY_MAX_DIST_SQ: f32 = LONDON_DISPLAY_MAX_DEGREES * LONDON_DISPLAY_MAX_DEGREES;
fn type_rank(place_type: &str) -> u8 {
match place_type {
"city" => 0,
@ -37,6 +47,53 @@ pub fn is_travel_destination_type(place_type: &str) -> bool {
matches!(place_type, "city" | "station" | "university")
}
fn distance_sq(lat: f32, lon: f32, city: &CityCandidate<'_>) -> f32 {
let cos_lat = lat.to_radians().cos();
let dlat = city.lat - lat;
let dlon = (city.lon - lon) * cos_lat;
dlat * dlat + dlon * dlon
}
fn is_london_city_name(name: &str) -> bool {
matches!(name, "London" | "Westminster" | "City of London")
}
pub(super) fn nearest_display_city<'a>(
lat: f32,
lon: f32,
cities: &'a [CityCandidate<'a>],
) -> Option<&'a str> {
let mut best_dist_sq = f32::MAX;
let mut best_city: Option<&CityCandidate<'_>> = None;
let mut london_dist_sq: Option<f32> = None;
for city in cities {
let dist_sq = distance_sq(lat, lon, city);
if city.name == "London" {
london_dist_sq = Some(dist_sq);
}
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_city = Some(city);
}
}
let best_city = best_city?;
if best_dist_sq >= PARENT_CITY_MAX_DIST_SQ {
return None;
}
if is_london_city_name(best_city.name) {
if london_dist_sq.is_some_and(|dist_sq| dist_sq < LONDON_DISPLAY_MAX_DIST_SQ) {
Some("London")
} else {
None
}
} else {
Some(best_city.name)
}
}
pub fn normalize_search_text(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut last_was_space = true;
@ -182,6 +239,25 @@ fn extract_bool_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<bool>> {
.collect()
}
fn extract_optional_str_col(
df: &DataFrame,
name: &str,
) -> anyhow::Result<Option<Vec<Option<String>>>> {
let column = match df.column(name) {
Ok(column) => column,
Err(_) => return Ok(None),
};
let string_column = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(Some(
string_column
.into_iter()
.map(|value| value.map(ToString::to_string))
.collect(),
))
}
impl PlaceData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
super::run_polars_io(|| Self::load_inner(parquet_path))
@ -227,6 +303,7 @@ impl PlaceData {
.map(|place_type| is_travel_destination_type(place_type))
.collect()
};
let display_city_override = extract_optional_str_col(&df, "display_city")?;
// Precompute nearest city for each non-city place
let city_indices: Vec<usize> = type_rank_vec
@ -234,37 +311,45 @@ impl PlaceData {
.enumerate()
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
.collect();
let city_candidates: Vec<CityCandidate<'_>> = city_indices
.iter()
.map(|&idx| CityCandidate {
name: &name[idx],
lat: lat[idx],
lon: lon[idx],
})
.collect();
let city: Vec<Option<String>> = (0..row_count)
let fallback_city: Vec<Option<String>> = (0..row_count)
.map(|idx| {
if type_rank_vec[idx] == 0 {
return None; // Cities don't need a city label
}
let plat = lat[idx];
let plon = lon[idx];
let cos_lat = (plat.to_radians()).cos();
let mut best_dist_sq = f32::MAX;
let mut best_city: Option<&str> = None;
for &ci in &city_indices {
let dlat = lat[ci] - plat;
let dlon = (lon[ci] - plon) * cos_lat;
let dist_sq = dlat * dlat + dlon * dlon;
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_city = Some(&name[ci]);
}
}
// ~100km threshold: 1° ≈ 111km, so 0.9° ≈ 100km → 0.81 squared
if best_dist_sq < 0.81 {
best_city.map(|s| s.to_string())
} else {
None
}
nearest_display_city(lat[idx], lon[idx], &city_candidates).map(str::to_string)
})
.collect();
let city: Vec<Option<String>> = if let Some(display_city_override) = display_city_override {
fallback_city
.into_iter()
.zip(display_city_override)
.enumerate()
.map(|(idx, (fallback, override_city))| {
if type_rank_vec[idx] == 0 {
return None;
}
override_city
.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
.or(fallback)
})
.collect()
} else {
fallback_city
};
let with_pop = population.iter().filter(|&&pop| pop > 0).count();
let with_city = city.iter().filter(|c| c.is_some()).count();
info!(
@ -294,6 +379,36 @@ impl PlaceData {
mod tests {
use super::*;
fn test_city_candidates() -> Vec<CityCandidate<'static>> {
vec![
CityCandidate {
name: "London",
lat: 51.5074456,
lon: -0.1277653,
},
CityCandidate {
name: "Westminster",
lat: 51.4973206,
lon: -0.137149,
},
CityCandidate {
name: "City of London",
lat: 51.5156177,
lon: -0.0919983,
},
CityCandidate {
name: "Cambridge",
lat: 52.2055314,
lon: 0.1186637,
},
CityCandidate {
name: "Oxford",
lat: 51.7520131,
lon: -1.2578499,
},
]
}
#[test]
fn type_rank_ordering() {
assert!(type_rank("city") < type_rank("town"));
@ -316,4 +431,41 @@ mod tests {
assert!(!is_travel_destination_type("town"));
assert!(!is_travel_destination_type("suburb"));
}
#[test]
fn nearest_display_city_canonicalizes_greater_london_aliases() {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(51.3713049, -0.101957, &cities),
Some("London")
);
}
#[test]
fn nearest_display_city_preserves_non_london_duplicates() {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(52.1277704, -0.0813098, &cities),
Some("Cambridge")
);
}
#[test]
fn nearest_display_city_does_not_leak_westminster_label_past_london_guard() {
let cities = test_city_candidates();
assert_eq!(nearest_display_city(51.5093, -0.5954, &cities), None);
}
#[test]
fn nearest_display_city_keeps_normal_non_london_city() {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(51.456659, -0.969651, &cities),
Some("Oxford")
);
}
}

View file

@ -6,6 +6,7 @@ use std::fs;
use std::path::Path;
use tracing::{debug, info};
use super::places::{nearest_display_city, CityCandidate};
use super::PlaceData;
/// Precomputed outcode data derived from postcode boundaries.
@ -58,29 +59,18 @@ impl OutcodeData {
.enumerate()
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
.collect();
let city_candidates: Vec<CityCandidate<'_>> = city_indices
.iter()
.map(|&idx| CityCandidate {
name: &place_data.name[idx],
lat: place_data.lat[idx],
lon: place_data.lon[idx],
})
.collect();
let cities: Vec<Option<String>> = centroids
.iter()
.map(|&(lat, lon)| {
let cos_lat = lat.to_radians().cos();
let mut best_dist_sq = f32::MAX;
let mut best_city: Option<&str> = None;
for &ci in &city_indices {
let dlat = place_data.lat[ci] - lat;
let dlon = (place_data.lon[ci] - lon) * cos_lat;
let dist_sq = dlat * dlat + dlon * dlon;
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_city = Some(&place_data.name[ci]);
}
}
// ~100km threshold
if best_dist_sq < 0.81 {
best_city.map(|s| s.to_string())
} else {
None
}
})
.map(|&(lat, lon)| nearest_display_city(lat, lon, &city_candidates).map(str::to_string))
.collect();
info!(

View file

@ -160,6 +160,11 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
],
},
FeatureGroup {
name: "Defining characteristics",
features: &[
Feature::Numeric(FeatureConfig {
name: "Street tree density percentile",
bounds: Bounds::Fixed {
@ -175,6 +180,21 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: true,
}),
Feature::Numeric(FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {
min: 50.0,
max: 80.0,
},
step: 1.0,
description: "Maximum transport noise level near the postcode in decibels (Lden)",
detail: "Maximum road, rail, or airport noise level in decibels (Lden, a 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
source: "noise",
prefix: "",
suffix: " dB",
raw: false,
absolute: false,
}),
],
},
FeatureGroup {
@ -270,7 +290,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
],
},
FeatureGroup {
name: "Education",
name: "Schools",
features: &[
Feature::Numeric(FeatureConfig {
name: "Good+ primary schools within 2km",
@ -983,21 +1003,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
name: "Amenities",
features: &[
Feature::Numeric(FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {
min: 50.0,
max: 80.0,
},
step: 1.0,
description: "Maximum transport noise level near the postcode in decibels (Lden)",
detail: "Maximum road, rail, or airport noise level in decibels (Lden, a 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
source: "noise",
prefix: "",
suffix: " dB",
raw: false,
absolute: false,
}),
Feature::Enum(EnumFeatureConfig {
name: "Max available download speed (Mbps)",
order: Some(&["10", "30", "100", "300", "1000"]),

View file

@ -2,6 +2,7 @@
mod aggregation;
mod auth;
mod bugsink;
mod checkout_sessions;
mod consts;
mod data;
@ -29,6 +30,7 @@ use axum::Router;
use clap::Parser;
use consts::SERVICE_CALL_TIMEOUT;
use tower::limit::ConcurrencyLimitLayer;
use tower::ServiceBuilder;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
@ -223,10 +225,87 @@ struct Cli {
/// Google OAuth client secret for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
google_oauth_client_secret: String,
/// Bugsink DSN for backend error reporting
#[arg(long, env = "BUGSINK_DSN", hide_env_values = true)]
bugsink_dsn: Option<String>,
/// Bugsink DSN injected into the browser app; falls back to BUGSINK_DSN when omitted
#[arg(long, env = "FRONTEND_BUGSINK_DSN", hide_env_values = true)]
frontend_bugsink_dsn: Option<String>,
/// Bugsink/Sentry environment name
#[arg(long, env = "BUGSINK_ENVIRONMENT")]
bugsink_environment: Option<String>,
/// Bugsink/Sentry release name
#[arg(long, env = "BUGSINK_RELEASE")]
bugsink_release: Option<String>,
/// Include default PII in Bugsink events
#[arg(long, env = "BUGSINK_SEND_DEFAULT_PII", default_value_t = false)]
bugsink_send_default_pii: bool,
}
async fn capture_server_error_responses(
request: axum::extract::Request,
next: middleware::Next,
) -> axum::response::Response {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let response = next.run(request).await;
let status = response.status();
if status.is_server_error() {
sentry::with_scope(
|scope| {
scope.set_tag("http.status_code", status.as_u16().to_string());
scope.set_tag("http.method", method.to_string());
scope.set_tag("http.route", path.clone());
},
|| {
sentry::capture_message(
&format!("HTTP {status} response from {method} {path}"),
sentry::Level::Error,
);
},
);
}
response
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let bugsink_environment = cli
.bugsink_environment
.clone()
.or_else(|| bugsink::env_nonempty("SENTRY_ENVIRONMENT"));
let bugsink_release = cli
.bugsink_release
.clone()
.or_else(|| bugsink::env_nonempty("SENTRY_RELEASE"));
let backend_bugsink_dsn = cli
.bugsink_dsn
.clone()
.or_else(|| bugsink::env_nonempty("SENTRY_DSN"));
let _bugsink_guard = bugsink::init_backend(&bugsink::BackendConfig {
dsn: backend_bugsink_dsn.clone(),
environment: bugsink_environment.clone(),
release: bugsink_release.clone(),
send_default_pii: cli.bugsink_send_default_pii,
});
let bugsink_frontend_config = bugsink::frontend_config(
cli.frontend_bugsink_dsn
.clone()
.or_else(|| bugsink::env_nonempty("PUBLIC_BUGSINK_DSN"))
.or(backend_bugsink_dsn),
bugsink_environment.clone(),
bugsink_release.clone(),
cli.bugsink_send_default_pii,
);
let file_appender = tracing_appender::rolling::daily("logs", "server.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
@ -234,6 +313,7 @@ async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(env_filter)
.with(sentry::integrations::tracing::layer())
.with(tracing_subscriber::fmt::layer().with_ansi(true))
.with(
tracing_subscriber::fmt::layer()
@ -245,8 +325,9 @@ async fn main() -> anyhow::Result<()> {
// Initialize Prometheus metrics
let metrics_handle = metrics::init_metrics();
info!("Prometheus metrics initialized");
let cli = Cli::parse();
if bugsink_frontend_config.is_some() || _bugsink_guard.is_some() {
info!("Bugsink error reporting configured");
}
for (label, path) in [
("Properties", &cli.properties),
@ -483,6 +564,7 @@ async fn main() -> anyhow::Result<()> {
stripe_secret_key: cli.stripe_secret_key,
stripe_webhook_secret: cli.stripe_webhook_secret,
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
bugsink_frontend_config,
};
let shared = Arc::new(SharedState::new(app_state));
@ -670,9 +752,7 @@ 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 |connect_info| metrics::metrics_handler(metrics_handle.clone(), connect_info)),
)
.with_state(shared.clone());
@ -696,9 +776,17 @@ async fn main() -> anyhow::Result<()> {
},
))
.layer(middleware::from_fn(static_cache_headers))
.layer(middleware::from_fn(capture_server_error_responses))
.layer(cors)
.layer(CompressionLayer::new().zstd(true).gzip(true))
.layer(TraceLayer::new_for_http());
.layer(TraceLayer::new_for_http())
.layer(
ServiceBuilder::new()
.layer(sentry::integrations::tower::NewSentryLayer::<
axum::extract::Request,
>::new_from_top())
.layer(sentry::integrations::tower::SentryHttpLayer::new()),
);
// Lock all current and future memory pages to prevent swapping
unsafe {

View file

@ -174,9 +174,9 @@ fn is_same_network(ip: IpAddr) -> bool {
v6.is_loopback()
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| v6.to_ipv4_mapped().is_some_and(|v4| {
v4.is_loopback() || v4.is_private() || v4.is_link_local()
})
|| v6
.to_ipv4_mapped()
.is_some_and(|v4| v4.is_loopback() || v4.is_private() || v4.is_link_local())
}
}
}

View file

@ -12,6 +12,7 @@ use crate::state::AppState;
const OG_PLACEHOLDER: &str =
r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
const BUGSINK_CONFIG_PLACEHOLDER: &str = "__PERFECT_POSTCODE_BUGSINK_CONFIG__";
const HTML_BODY_LIMIT: usize = 5 * 1024 * 1024;
@ -318,6 +319,26 @@ fn inject_tags(mut html: String, page: &SeoPage, tags: &str) -> String {
html
}
fn escape_json_for_script_tag(json: &str) -> String {
json.replace('&', "\\u0026")
.replace('<', "\\u003c")
.replace('>', "\\u003e")
}
fn inject_bugsink_config(html: String, config: Option<&crate::bugsink::FrontendConfig>) -> String {
if !html.contains(BUGSINK_CONFIG_PLACEHOLDER) {
return html;
}
let json = config
.and_then(|config| serde_json::to_string(config).ok())
.unwrap_or_else(|| "{}".to_string());
html.replace(
BUGSINK_CONFIG_PLACEHOLDER,
&escape_json_for_script_tag(&json),
)
}
pub async fn og_middleware(request: Request, next: Next) -> Response {
let path = request.uri().path().to_string();
// Capture the query string before passing the request through
@ -360,10 +381,10 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
None => return response,
};
let page = match seo_page_for_path(&path) {
Some(page) => page,
None => return response,
};
let page = seo_page_for_path(&path);
if page.is_none() && state.bugsink_frontend_config.is_none() {
return response;
}
let (mut parts, body) = response.into_parts();
let bytes = match to_bytes(body, HTML_BODY_LIMIT).await {
@ -377,8 +398,11 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
};
let html = String::from_utf8_lossy(&bytes).into_owned();
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url, language);
let html = inject_tags(html, &page, &tags);
let mut html = inject_bugsink_config(html, state.bugsink_frontend_config.as_ref());
if let Some(page) = page {
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url, language);
html = inject_tags(html, &page, &tags);
}
parts.headers.remove(header::CONTENT_LENGTH);
Response::from_parts(parts, Body::from(html))
}

View file

@ -10,7 +10,13 @@ use crate::data::{Histogram, PropertyData};
use crate::features::{self, Feature, FEATURE_GROUPS};
use crate::state::SharedState;
const FILTER_GROUP_ORDER: &[&str] = &["Transport", "Property prices", "Properties", "Amenities"];
const FILTER_GROUP_ORDER: &[&str] = &[
"Transport",
"Property prices",
"Properties",
"Defining characteristics",
"Amenities",
];
const LAST_FILTER_GROUPS: &[&str] = &["Area development"];
const POI_DISTANCE_SLIDER_MIN_KM: f32 = 0.0;
const POI_DISTANCE_SLIDER_MAX_KM: f32 = 5.0;
@ -268,11 +274,12 @@ mod tests {
fn orders_filter_groups_for_backend_response() {
let mut groups = vec![
group("Properties"),
group("Education"),
group("Schools"),
group("Area development"),
group("Property prices"),
group("Crime"),
group("Neighbours"),
group("Defining characteristics"),
group("Amenities"),
group("Transport"),
];
@ -286,8 +293,9 @@ mod tests {
"Transport",
"Property prices",
"Properties",
"Defining characteristics",
"Amenities",
"Education",
"Schools",
"Crime",
"Neighbours",
"Area development",

View file

@ -633,7 +633,8 @@ mod tests {
assert!(fields_specified);
assert!(field_set.contains("Property type"));
assert!(!field_set.contains("Noise (dB)"));
assert!(field_set.contains("Street tree density percentile"));
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)"));
}

View file

@ -1,14 +1,14 @@
use std::sync::Arc;
use axum::Extension;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Json;
use axum::Extension;
use serde::{Deserialize, Serialize};
use crate::auth::OptionalUser;
use crate::data::{PlaceData, slugify};
use crate::data::{slugify, PlaceData};
use crate::licensing::{check_license_point, resolve_share_code};
use crate::state::SharedState;
use crate::utils::normalize_postcode;

View file

@ -4,6 +4,7 @@ use parking_lot::RwLock;
use rustc_hash::FxHashMap;
use crate::auth::TokenCache;
use crate::bugsink::FrontendConfig as BugsinkFrontendConfig;
use crate::data::{
OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
};
@ -77,6 +78,8 @@ pub struct AppState {
pub stripe_webhook_secret: String,
/// Stripe Coupon ID for referral discounts
pub stripe_referral_coupon_id: String,
/// Bugsink/Sentry-compatible browser error reporting config injected into served HTML.
pub bugsink_frontend_config: Option<BugsinkFrontendConfig>,
}
/// Wraps AppState for shared access across route handlers.