use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::Redirect; use reqwest::Url; use serde::Deserialize; use serde_json::Value; use tracing::warn; use crate::state::SharedState; use crate::utils::normalize_postcode; const RIGHTMOVE_TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead"; const RIGHTMOVE_HOST: &str = "www.rightmove.co.uk"; const RIGHTMOVE_FIND_PATH: &str = "/property-for-sale/find.html"; #[derive(Deserialize)] pub struct RightmoveRedirectParams { postcode: String, target: String, } #[derive(Deserialize)] struct RightmoveTypeaheadResponse { #[serde(default)] matches: Vec, } #[derive(Deserialize)] struct RightmoveTypeaheadMatch { id: Value, #[serde(rename = "type")] match_type: String, #[serde(default, rename = "displayName")] display_name: String, } pub async fn get_rightmove_redirect( State(shared): State>, Query(params): Query, ) -> Result { if !looks_like_full_uk_postcode(¶ms.postcode) { return Err(( StatusCode::BAD_REQUEST, "'postcode' must be a full UK postcode".to_string(), )); } let postcode = normalize_postcode(¶ms.postcode); let mut target = parse_rightmove_target(¶ms.target)?; let state = shared.load_state(); match fetch_exact_postcode_location_identifier(&state.http_client, &postcode).await { Some(location_identifier) => { apply_exact_postcode_location(&mut target, &postcode, &location_identifier); } None => warn!( postcode, "Could not resolve exact Rightmove postcode location" ), } Ok(Redirect::temporary(target.as_str())) } async fn fetch_exact_postcode_location_identifier( client: &reqwest::Client, postcode: &str, ) -> Option { let url = format!( "{}?query={}&limit=5", RIGHTMOVE_TYPEAHEAD_URL, urlencoding::encode(postcode) ); let response = client .get(url) .send() .await .map_err(|err| warn!(postcode, "Rightmove typeahead request failed: {err}")) .ok()?; if !response.status().is_success() { warn!( postcode, status = %response.status(), "Rightmove typeahead returned an error" ); return None; } let typeahead: RightmoveTypeaheadResponse = response .json() .await .map_err(|err| { warn!( postcode, "Failed to parse Rightmove typeahead response: {err}" ) }) .ok()?; typeahead.matches.iter().find_map(|item| { if !item.match_type.eq_ignore_ascii_case("POSTCODE") { return None; } if compact_postcode(&item.display_name) != compact_postcode(postcode) { return None; } rightmove_id_to_string(&item.id).map(|id| format!("POSTCODE^{id}")) }) } fn parse_rightmove_target(target: &str) -> Result { let url = Url::parse(target).map_err(|_| { ( StatusCode::BAD_REQUEST, "'target' must be a valid Rightmove URL".to_string(), ) })?; if url.scheme() != "https" || url.host_str() != Some(RIGHTMOVE_HOST) || url.path() != RIGHTMOVE_FIND_PATH { return Err(( StatusCode::BAD_REQUEST, "'target' must be a Rightmove property search URL".to_string(), )); } Ok(url) } fn apply_exact_postcode_location(url: &mut Url, postcode: &str, location_identifier: &str) { let mut pairs: Vec<(String, String)> = url .query_pairs() .filter(|(key, _)| { key != "searchLocation" && key != "useLocationIdentifier" && key != "locationIdentifier" && key != "radius" }) .map(|(key, value)| (key.into_owned(), value.into_owned())) .collect(); pairs.push(("searchLocation".to_string(), postcode.to_string())); pairs.push(("useLocationIdentifier".to_string(), "true".to_string())); pairs.push(( "locationIdentifier".to_string(), location_identifier.to_string(), )); pairs.push(("radius".to_string(), "0.0".to_string())); let mut query = url.query_pairs_mut(); query.clear(); for (key, value) in pairs { query.append_pair(&key, &value); } } fn rightmove_id_to_string(value: &Value) -> Option { match value { Value::String(id) if !id.trim().is_empty() => Some(id.clone()), Value::Number(id) => Some(id.to_string()), _ => None, } } fn compact_postcode(postcode: &str) -> String { postcode .chars() .filter(|ch| !ch.is_whitespace()) .map(|ch| ch.to_ascii_uppercase()) .collect() } fn looks_like_full_uk_postcode(postcode: &str) -> bool { let compact = compact_postcode(postcode); let bytes = compact.as_bytes(); if !(5..=7).contains(&bytes.len()) { return false; } let outward_len = bytes.len() - 3; bytes[0].is_ascii_alphabetic() && bytes[..outward_len] .iter() .all(|byte| byte.is_ascii_alphanumeric()) && bytes[outward_len].is_ascii_digit() && bytes[outward_len + 1].is_ascii_alphabetic() && bytes[outward_len + 2].is_ascii_alphabetic() } #[cfg(test)] mod tests { use super::*; #[test] fn rewrites_rightmove_url_to_exact_postcode_location() { let mut url = Url::parse( "https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.25&minPrice=100000", ) .unwrap(); apply_exact_postcode_location(&mut url, "SW1A 1AA", "POSTCODE^837246"); let pairs: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect(); assert_eq!(pairs.get("searchLocation").unwrap(), "SW1A 1AA"); assert_eq!(pairs.get("useLocationIdentifier").unwrap(), "true"); assert_eq!(pairs.get("locationIdentifier").unwrap(), "POSTCODE^837246"); assert_eq!(pairs.get("radius").unwrap(), "0.0"); assert_eq!(pairs.get("minPrice").unwrap(), "100000"); } #[test] fn rejects_non_rightmove_redirect_targets() { assert!(parse_rightmove_target("https://example.com/property-for-sale/find.html").is_err()); assert!( parse_rightmove_target("http://www.rightmove.co.uk/property-for-sale/find.html") .is_err() ); assert!( parse_rightmove_target("https://www.rightmove.co.uk/property-to-rent/find.html") .is_err() ); } #[test] fn validates_full_postcode_shape() { assert!(looks_like_full_uk_postcode("SW1A 1AA")); assert!(looks_like_full_uk_postcode("e16an")); assert!(!looks_like_full_uk_postcode("SW1A")); assert!(!looks_like_full_uk_postcode("not a postcode")); } }