lgtm
This commit is contained in:
parent
084117cea8
commit
a8de0a614d
36 changed files with 1329 additions and 522 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue