This commit is contained in:
Andras Schmelczer 2026-05-14 22:07:14 +01:00
parent 084117cea8
commit a8de0a614d
36 changed files with 1329 additions and 522 deletions

View file

@ -12,6 +12,8 @@ use crate::state::SharedState;
const FILTER_GROUP_ORDER: &[&str] = &["Transport", "Property prices", "Properties", "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;
fn is_empty(val: &str) -> bool {
val.is_empty()
@ -163,8 +165,8 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
let is_park = category.eq_ignore_ascii_case("park");
dynamic_poi_features.push(FeatureInfo::Numeric {
name: name.clone(),
min: stats.slider_min,
max: stats.slider_max,
min: POI_DISTANCE_SLIDER_MIN_KM,
max: POI_DISTANCE_SLIDER_MAX_KM,
step: 0.1,
histogram: stats.histogram.clone(),
description: if is_park {
@ -187,7 +189,7 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
prefix: "",
suffix: " km",
raw: false,
absolute: false,
absolute: true,
});
} else if let Some(category) = features::dynamic_poi_count_category(name) {
let stats = &data.poi_metrics.feature_stats[feat_idx];

View file

@ -193,13 +193,31 @@ async fn verify_is_admin(
Ok(body["is_admin"].as_bool().unwrap_or(false))
}
async fn lookup_unused_invite(
fn redeemable_invite_filter(code: &str, user_id: &str) -> Result<String, &'static str> {
validate_invite_code(code)?;
if user_id.is_empty()
|| user_id.len() > 32
|| !user_id.bytes().all(|b| b.is_ascii_alphanumeric())
{
return Err("Invalid user id");
}
Ok(format!(
"code=\"{}\" && (used_by_id=\"\" || used_by_id=\"{}\")",
code, user_id
))
}
async fn lookup_redeemable_invite(
state: &AppState,
pb_url: &str,
token: &str,
code: &str,
user_id: &str,
) -> Result<Option<serde_json::Value>, Response> {
let filter = format!("code=\"{}\" && used_by_id=\"\"", code);
let filter = match redeemable_invite_filter(code, user_id) {
Ok(filter) => filter,
Err(msg) => return Err((StatusCode::BAD_REQUEST, msg).into_response()),
};
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
@ -590,7 +608,7 @@ pub async fn post_redeem_invite(
}
};
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
let invite = match lookup_redeemable_invite(&state, pb_url, &token, &req.code, &user.id).await {
Ok(Some(invite)) => invite,
Ok(None) => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
@ -617,13 +635,17 @@ pub async fn post_redeem_invite(
return StatusCode::BAD_GATEWAY.into_response();
}
};
let used_by_id = invite["used_by_id"].as_str().unwrap_or_default();
if !used_by_id.is_empty() && used_by_id != user.id {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response();
}
if invite_type == "admin" {
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
return response;
}
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
return response;
}
@ -635,6 +657,10 @@ pub async fn post_redeem_invite(
.into_response();
}
if !used_by_id.is_empty() {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response();
}
match active_referral_checkout_user(&state, invite_id).await {
Ok(Some(active_user_id)) if active_user_id != user.id => {
return (
@ -663,6 +689,26 @@ pub async fn post_redeem_invite(
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State<Arc<SharedState>>,

View file

@ -2,16 +2,22 @@ use std::sync::Arc;
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::licensing::{check_license_point, resolve_share_code};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
#[derive(Deserialize)]
pub struct JourneyQuery {
postcode: String,
mode: String,
slug: String,
share: Option<String>,
}
#[derive(Serialize)]
@ -26,16 +32,30 @@ pub struct JourneyResponse {
pub async fn get_journey(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
query: axum::extract::Query<JourneyQuery>,
) -> Result<Json<JourneyResponse>, (StatusCode, String)> {
) -> Result<Json<JourneyResponse>, axum::response::Response> {
let state = shared.load_state();
let store = &state.travel_time_store;
let postcode = normalize_postcode(&query.postcode);
let pc_idx = state
.postcode_data
.postcode_to_idx
.get(&postcode)
.copied()
.ok_or_else(|| (StatusCode::NOT_FOUND, "Postcode not found").into_response())?;
let (lat, lon) = state.postcode_data.centroids[pc_idx];
let share_bounds = resolve_share_code(&state, query.share.as_deref()).await;
check_license_point(&user.0, lat as f64, lon as f64, share_bounds)?;
if !store.has_destination(&query.mode, &query.slug) {
return Err((
StatusCode::NOT_FOUND,
format!("No travel data for mode={} slug={}", query.mode, query.slug),
));
)
.into_response());
}
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
@ -43,9 +63,10 @@ pub async fn get_journey(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load travel data: {e}"),
)
.into_response()
})?;
let row = travel_data.get(&query.postcode);
let row = travel_data.get(&postcode);
let journey = row
.and_then(|r| r.journey.as_ref())
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());

View file

@ -9,6 +9,31 @@ use tracing::warn;
use crate::state::SharedState;
/// PocketBase API paths the frontend is allowed to reach via /pb/*.
/// Everything else (admins API, settings, logs, backups, collection schema,
/// arbitrary collection records like checkout_sessions/invites/short_urls)
/// is rejected at the proxy layer as defense-in-depth on top of PB's own
/// collection rules.
fn is_allowed_pb_path(path: &str) -> bool {
// Exact paths
if matches!(
path,
"/api/health" | "/api/oauth2-redirect" | "/api/realtime"
) {
return true;
}
// 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/",
"/api/collections/saved_searches/",
"/api/files/",
];
ALLOWED_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix))
}
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
/// responses are passed through to the browser (needed for OAuth flows).
/// No overall timeout because SSE (Server-Sent Events) connections used by
@ -31,6 +56,13 @@ pub async fn proxy_to_pocketbase(
let path = req.uri().path();
let target_path = path.strip_prefix("/pb").unwrap_or(path);
if !is_allowed_pb_path(target_path) {
warn!(path = %target_path, "Rejected PocketBase proxy request to disallowed path");
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap();
}
let query = req
.uri()
.query()
@ -55,20 +87,9 @@ pub async fn proxy_to_pocketbase(
}
}
// Forward client IP so PocketBase rate-limits per-user, not per-server.
// Prefer existing X-Forwarded-For (from reverse proxy), fall back to X-Real-IP.
if let Some(xff) = req.headers().get("x-forwarded-for") {
builder = builder.header("X-Forwarded-For", xff.clone());
// First IP in the chain is the original client
if let Ok(s) = xff.to_str() {
if let Some(client_ip) = s.split(',').next().map(str::trim) {
builder = builder.header("X-Real-IP", client_ip);
}
}
} else if let Some(real_ip) = req.headers().get("x-real-ip") {
builder = builder.header("X-Forwarded-For", real_ip.clone());
builder = builder.header("X-Real-IP", real_ip.clone());
}
// Do not forward client-supplied X-Forwarded-For/X-Real-IP. PocketBase
// may use trusted proxy headers for rate limits, so accepting public
// values here lets callers choose their own source IP.
// Forward body
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {

View file

@ -7,6 +7,7 @@ use axum::response::IntoResponse;
use metrics::histogram;
use tracing::{info, warn};
use crate::language::{language_from_accept_language, query_string_with_language};
use crate::state::{AppState, SharedState};
/// Fetch a JPEG screenshot from the screenshot service.
@ -48,9 +49,19 @@ pub async fn get_screenshot(
let qs = uri.query().unwrap_or_default();
let auth = headers.get(header::AUTHORIZATION);
let is_og = qs.contains("og=1");
let query_string = if is_og {
let language = language_from_accept_language(
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok()),
);
query_string_with_language(qs, language)
} else {
qs.to_string()
};
let t0 = std::time::Instant::now();
let result = fetch_screenshot_bytes(&state, qs, auth).await;
let result = fetch_screenshot_bytes(&state, &query_string, auth).await;
let kind = if is_og { "og" } else { "export" };
histogram!("screenshot_duration_seconds", "kind" => kind).record(t0.elapsed().as_secs_f64());