lgtm
This commit is contained in:
parent
084117cea8
commit
a8de0a614d
36 changed files with 1329 additions and 522 deletions
|
|
@ -30,7 +30,7 @@ pub enum CheckoutStart {
|
|||
|
||||
pub enum CheckoutCompletion {
|
||||
Grant(VerifiedCheckout),
|
||||
AlreadyHandled,
|
||||
AlreadyHandled(VerifiedCheckout),
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +48,6 @@ struct PendingCheckout {
|
|||
id: String,
|
||||
user_id: String,
|
||||
stripe_session_id: String,
|
||||
stripe_payment_intent_id: String,
|
||||
checkout_url: String,
|
||||
amount_pence: u64,
|
||||
expected_total_pence: u64,
|
||||
|
|
@ -258,10 +257,8 @@ pub async fn verify_checkout_completion(
|
|||
}
|
||||
};
|
||||
|
||||
if checkout.status == "completed" {
|
||||
return Ok(CheckoutCompletion::AlreadyHandled);
|
||||
}
|
||||
if checkout.status != "pending" && checkout.status != "expired" {
|
||||
let already_completed = checkout.status == "completed";
|
||||
if !already_completed && checkout.status != "pending" && checkout.status != "expired" {
|
||||
return Ok(CheckoutCompletion::Rejected(format!(
|
||||
"checkout reservation is {}",
|
||||
checkout.status
|
||||
|
|
@ -332,14 +329,20 @@ pub async fn verify_checkout_completion(
|
|||
));
|
||||
}
|
||||
|
||||
Ok(CheckoutCompletion::Grant(VerifiedCheckout {
|
||||
let verified = VerifiedCheckout {
|
||||
reservation_id: checkout.id,
|
||||
user_id: checkout.user_id,
|
||||
stripe_session_id: session_id.to_string(),
|
||||
payment_intent_id: payment_intent_id.to_string(),
|
||||
paid_amount_pence: amount_total,
|
||||
referral_invite_id: checkout.referral_invite_id,
|
||||
}))
|
||||
};
|
||||
|
||||
if already_completed {
|
||||
Ok(CheckoutCompletion::AlreadyHandled(verified))
|
||||
} else {
|
||||
Ok(CheckoutCompletion::Grant(verified))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_checkout_completed(
|
||||
|
|
@ -421,14 +424,6 @@ async fn complete_verified_checkout_locked(
|
|||
return Err(anyhow!("checkout reservation is {}", live_checkout.status));
|
||||
}
|
||||
|
||||
grant_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_completed(
|
||||
state,
|
||||
&checkout.reservation_id,
|
||||
checkout.paid_amount_pence,
|
||||
&checkout.payment_intent_id,
|
||||
)
|
||||
.await?;
|
||||
if !checkout.referral_invite_id.is_empty() {
|
||||
mark_referral_invite_used(
|
||||
state,
|
||||
|
|
@ -438,6 +433,14 @@ async fn complete_verified_checkout_locked(
|
|||
)
|
||||
.await?;
|
||||
}
|
||||
grant_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_completed(
|
||||
state,
|
||||
&checkout.reservation_id,
|
||||
checkout.paid_amount_pence,
|
||||
&checkout.payment_intent_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -459,65 +462,10 @@ pub async fn grant_license_with_pricing_lock(
|
|||
result
|
||||
}
|
||||
|
||||
pub async fn reverse_license_for_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||
}
|
||||
|
||||
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||
let pricing_lock = acquire_pocketbase_lock(
|
||||
state,
|
||||
CHECKOUT_PRICING_LOCK_NAME,
|
||||
CHECKOUT_PRICING_LOCK_TTL_SECS,
|
||||
)
|
||||
.await?;
|
||||
let result = reverse_license_for_payment_intent_locked(state, payment_intent_id, reason).await;
|
||||
if let Err(err) = pricing_lock.release().await {
|
||||
warn!("Failed to release checkout pricing lock: {err}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn reverse_license_for_payment_intent_locked(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let Some(checkout) = find_checkout_by_payment_intent(state, payment_intent_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if checkout.stripe_payment_intent_id != payment_intent_id {
|
||||
return Err(anyhow!("checkout payment intent mismatch"));
|
||||
}
|
||||
if checkout.status == "refunded" || checkout.status == "disputed" {
|
||||
return Ok(Some(checkout.user_id));
|
||||
}
|
||||
if checkout.status != "completed" {
|
||||
return Ok(Some(checkout.user_id));
|
||||
}
|
||||
|
||||
let reversed_status = if reason.contains("dispute") {
|
||||
"disputed"
|
||||
} else {
|
||||
"refunded"
|
||||
};
|
||||
revoke_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_reversed(state, &checkout.id, reversed_status, reason).await?;
|
||||
Ok(Some(checkout.user_id))
|
||||
}
|
||||
|
||||
pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
set_user_subscription(state, user_id, "licensed").await
|
||||
}
|
||||
|
||||
async fn revoke_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
set_user_subscription(state, user_id, "free").await
|
||||
}
|
||||
|
||||
async fn set_user_subscription(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
|
|
@ -768,20 +716,13 @@ async fn count_active_pending_checkouts(state: &AppState, now: u64) -> anyhow::R
|
|||
async fn find_active_checkout_for_user(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
_discount_coupon_id: &str,
|
||||
_referral_invite_id: &str,
|
||||
discount_coupon_id: &str,
|
||||
referral_invite_id: &str,
|
||||
now: u64,
|
||||
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||
if !is_safe_pocketbase_id(user_id) {
|
||||
return Err(anyhow!("invalid PocketBase user id"));
|
||||
}
|
||||
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!(
|
||||
"status=\"pending\" && expires_at_unix>={now} && user=\"{}\"",
|
||||
user_id
|
||||
);
|
||||
let filter = active_checkout_filter(user_id, discount_coupon_id, referral_invite_id, now)?;
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
|
|
@ -804,6 +745,27 @@ async fn find_active_checkout_for_user(
|
|||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
fn active_checkout_filter(
|
||||
user_id: &str,
|
||||
discount_coupon_id: &str,
|
||||
referral_invite_id: &str,
|
||||
now: u64,
|
||||
) -> anyhow::Result<String> {
|
||||
if !is_safe_pocketbase_id(user_id) {
|
||||
return Err(anyhow!("invalid PocketBase user id"));
|
||||
}
|
||||
if !discount_coupon_id.is_empty() && !is_safe_stripe_session_id(discount_coupon_id) {
|
||||
return Err(anyhow!("invalid Stripe coupon id"));
|
||||
}
|
||||
if !referral_invite_id.is_empty() && !is_safe_pocketbase_id(referral_invite_id) {
|
||||
return Err(anyhow!("invalid PocketBase referral invite id"));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"status=\"pending\" && expires_at_unix>={now} && user=\"{user_id}\" && discount_coupon_id=\"{discount_coupon_id}\" && referral_invite_id=\"{referral_invite_id}\""
|
||||
))
|
||||
}
|
||||
|
||||
async fn expire_stale_pending_checkouts(state: &AppState, now: u64) -> anyhow::Result<()> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
|
@ -1076,31 +1038,6 @@ async fn mark_checkout_status(
|
|||
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
|
||||
}
|
||||
|
||||
async fn mark_checkout_reversed(
|
||||
state: &AppState,
|
||||
reservation_id: &str,
|
||||
status: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
||||
let resp = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"status": status,
|
||||
"reversal_reason": reason,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success(resp)
|
||||
.await
|
||||
.with_context(|| format!("PocketBase checkout reversal update failed for {reservation_id}"))
|
||||
}
|
||||
|
||||
async fn find_checkout_by_stripe_session(
|
||||
state: &AppState,
|
||||
stripe_session_id: &str,
|
||||
|
|
@ -1130,35 +1067,6 @@ async fn find_checkout_by_stripe_session(
|
|||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
async fn find_checkout_by_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("stripe_payment_intent_id=\"{}\"", payment_intent_id);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success_ref(&resp).await?;
|
||||
|
||||
let body: Value = resp.json().await?;
|
||||
let item = body["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.cloned();
|
||||
|
||||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
||||
Ok(PendingCheckout {
|
||||
id: item["id"]
|
||||
|
|
@ -1173,10 +1081,6 @@ fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
|||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
stripe_payment_intent_id: item["stripe_payment_intent_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
checkout_url: item["checkout_url"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
|
|
@ -1242,3 +1146,40 @@ async fn ensure_success_ref(resp: &reqwest::Response) -> anyhow::Result<()> {
|
|||
|
||||
Err(anyhow!("upstream returned {}", resp.status()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn active_checkout_filter_includes_empty_context_for_standard_checkout() {
|
||||
let filter = active_checkout_filter("abc123", "", "", 42).unwrap();
|
||||
assert_eq!(
|
||||
filter,
|
||||
"status=\"pending\" && expires_at_unix>=42 && user=\"abc123\" && discount_coupon_id=\"\" && referral_invite_id=\"\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_checkout_filter_includes_referral_context() {
|
||||
let filter = active_checkout_filter("user123", "coupon_30", "invite123", 99).unwrap();
|
||||
assert_eq!(
|
||||
filter,
|
||||
"status=\"pending\" && expires_at_unix>=99 && user=\"user123\" && discount_coupon_id=\"coupon_30\" && referral_invite_id=\"invite123\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_checkout_filter_rejects_unsafe_context_values() {
|
||||
assert!(active_checkout_filter("user123", "bad\"coupon", "", 1).is_err());
|
||||
assert!(active_checkout_filter("user123", "", "bad-invite", 1).is_err());
|
||||
assert!(active_checkout_filter("bad-user", "", "", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_total_for_referral_discount_rounds_down_like_stripe_amount_math() {
|
||||
assert_eq!(expected_total_for_checkout(999, Some("coupon_30")), 699);
|
||||
assert_eq!(expected_total_for_checkout(1, Some("coupon_30")), 1);
|
||||
assert_eq!(expected_total_for_checkout(999, None), 999);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
server-rs/src/language.rs
Normal file
101
server-rs/src/language.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
pub const DEFAULT_LANGUAGE: &str = "en";
|
||||
|
||||
const SUPPORTED_LANGUAGES: &[&str] = &["en", "fr", "de", "zh", "hi", "hu"];
|
||||
|
||||
pub fn supported_language(value: &str) -> Option<&'static str> {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
if value.is_empty() || value == "*" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let primary = value.split('-').next().unwrap_or("");
|
||||
SUPPORTED_LANGUAGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|language| value == *language || primary == *language)
|
||||
}
|
||||
|
||||
pub fn language_from_accept_language(header: Option<&str>) -> &'static str {
|
||||
let Some(header) = header else {
|
||||
return DEFAULT_LANGUAGE;
|
||||
};
|
||||
|
||||
let mut best_language = None;
|
||||
let mut best_quality = 0.0f32;
|
||||
|
||||
for item in header.split(',') {
|
||||
let mut parts = item.split(';');
|
||||
let tag = parts.next().unwrap_or("").trim();
|
||||
let mut quality = 1.0f32;
|
||||
|
||||
for param in parts {
|
||||
let param = param.trim();
|
||||
if let Some(value) = param.strip_prefix("q=") {
|
||||
quality = value.parse::<f32>().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
if quality <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(language) = supported_language(tag) {
|
||||
if quality > best_quality {
|
||||
best_language = Some(language);
|
||||
best_quality = quality;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_language.unwrap_or(DEFAULT_LANGUAGE)
|
||||
}
|
||||
|
||||
pub fn query_string_with_language(query_string: &str, language: &str) -> String {
|
||||
if url::form_urlencoded::parse(query_string.as_bytes()).any(|(key, _)| key == "lang") {
|
||||
return query_string.to_string();
|
||||
}
|
||||
|
||||
if query_string.is_empty() {
|
||||
format!("lang={language}")
|
||||
} else {
|
||||
format!("{query_string}&lang={language}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_browser_language_tags_to_supported_languages() {
|
||||
assert_eq!(supported_language("fr-CA"), Some("fr"));
|
||||
assert_eq!(supported_language("zh-Hans-CN"), Some("zh"));
|
||||
assert_eq!(supported_language("es"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_highest_quality_supported_language() {
|
||||
assert_eq!(
|
||||
language_from_accept_language(Some("en-US;q=0.6, fr-FR;q=0.9, de;q=0.7")),
|
||||
"fr"
|
||||
);
|
||||
assert_eq!(
|
||||
language_from_accept_language(Some("es-ES, hi-IN;q=0.8")),
|
||||
"hi"
|
||||
);
|
||||
assert_eq!(language_from_accept_language(Some("es-ES")), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_language_without_overriding_explicit_query_language() {
|
||||
assert_eq!(query_string_with_language("", "de"), "lang=de");
|
||||
assert_eq!(
|
||||
query_string_with_language("lat=51.5&lon=-0.1", "de"),
|
||||
"lat=51.5&lon=-0.1&lang=de"
|
||||
);
|
||||
assert_eq!(
|
||||
query_string_with_language("lang=fr&lat=1", "de"),
|
||||
"lang=fr&lat=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,15 +6,16 @@ use parking_lot::RwLock;
|
|||
use rustc_hash::FxHashMap;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::warn;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::auth::PocketBaseUser;
|
||||
use crate::consts::FREE_ZONE_BOUNDS;
|
||||
use crate::consts::{
|
||||
FREE_ZONE_BOUNDS, MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM,
|
||||
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS,
|
||||
};
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
const SHARE_CACHE_TTL_SECS: u64 = 300;
|
||||
const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShareBounds {
|
||||
pub south: f64,
|
||||
|
|
@ -72,9 +73,8 @@ impl Default for ShareBoundsCache {
|
|||
}
|
||||
|
||||
/// Resolve a share code to the bbox the share grants access to.
|
||||
/// Looks up the stored params for the code in PocketBase, parses lat/lon/zoom,
|
||||
/// and derives a generous bbox sized to roughly 4× the viewport at that zoom.
|
||||
/// Returns `None` if the code is invalid or unknown.
|
||||
/// Looks up an explicit server-created grant on the short URL record. Legacy
|
||||
/// records that only stored raw params intentionally grant no access.
|
||||
pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
|
||||
if !is_valid_share_code(code) {
|
||||
return None;
|
||||
|
|
@ -127,29 +127,68 @@ async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds>
|
|||
return None;
|
||||
}
|
||||
let json: Value = resp.json().await.ok()?;
|
||||
let params = json["items"].as_array()?.first()?.get("params")?.as_str()?;
|
||||
parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom))
|
||||
let item = json["items"].as_array()?.first()?;
|
||||
let bounds = ShareBounds {
|
||||
south: number_field(item, "share_south")?,
|
||||
west: number_field(item, "share_west")?,
|
||||
north: number_field(item, "share_north")?,
|
||||
east: number_field(item, "share_east")?,
|
||||
};
|
||||
is_valid_share_bounds(bounds).then_some(bounds)
|
||||
}
|
||||
|
||||
/// Pull `lat`, `lon`, `zoom` out of an already-encoded query string like
|
||||
/// `lat=51.5&lon=-0.1&zoom=12&filter=...`. Returns `None` if any of the three
|
||||
/// is missing or unparseable — those are the only fields we need for sizing.
|
||||
fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> {
|
||||
fn number_field(item: &Value, field: &str) -> Option<f64> {
|
||||
item.get(field)?.as_f64().filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
/// Build share params and bounds for a new share code. If the source view is
|
||||
/// broader than a share grant may cover, clamp the stored zoom around the same
|
||||
/// center so recipients open inside the created grant instead of being blocked
|
||||
/// on first load.
|
||||
pub fn share_params_and_bounds_from_params(params: &str) -> Option<(String, ShareBounds)> {
|
||||
let mut lat: Option<f64> = None;
|
||||
let mut lon: Option<f64> = None;
|
||||
let mut zoom: Option<f64> = None;
|
||||
for pair in params.split('&') {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
let key = it.next()?;
|
||||
let val = it.next().unwrap_or("");
|
||||
match key {
|
||||
"lat" => lat = val.parse().ok(),
|
||||
"lon" => lon = val.parse().ok(),
|
||||
"zoom" => zoom = val.parse().ok(),
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"lat" => lat = value.parse().ok(),
|
||||
"lon" => lon = value.parse().ok(),
|
||||
"zoom" => zoom = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
pairs.push((key.into_owned(), value.into_owned()));
|
||||
}
|
||||
Some((lat?, lon?, zoom?))
|
||||
|
||||
let lat = lat?;
|
||||
let lon = lon?;
|
||||
let zoom = zoom?;
|
||||
if !lat.is_finite()
|
||||
|| !lon.is_finite()
|
||||
|| !zoom.is_finite()
|
||||
|| !(-90.0..=90.0).contains(&lat)
|
||||
|| !(-180.0..=180.0).contains(&lon)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
|
||||
let bounds = bounds_from_view(lat, lon, zoom);
|
||||
if !is_valid_share_bounds(bounds) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = form_urlencoded::Serializer::new(String::new());
|
||||
for (key, value) in pairs {
|
||||
if key == "zoom" {
|
||||
out.append_pair(&key, &format!("{zoom:.1}"));
|
||||
} else {
|
||||
out.append_pair(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
Some((out.finish(), bounds))
|
||||
}
|
||||
|
||||
/// Derive the share bbox from the share's center lat/lon and zoom.
|
||||
|
|
@ -160,17 +199,30 @@ fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> {
|
|||
/// ~2 viewports per side (~4 viewports total area). Lat is scaled by 0.6
|
||||
/// to roughly match the latitude compression at UK latitudes.
|
||||
fn bounds_from_view(lat: f64, lon: f64, zoom: f64) -> ShareBounds {
|
||||
let zoom = zoom.clamp(0.0, 20.0);
|
||||
let half_lon = (1800.0 / 2.0_f64.powf(zoom)).min(180.0);
|
||||
let half_lat = (half_lon * 0.6).min(85.0);
|
||||
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
|
||||
let half_lon = (1800.0 / 2.0_f64.powf(zoom))
|
||||
.min(MAX_SHARE_LON_SPAN / 2.0)
|
||||
.min(180.0);
|
||||
let half_lat = (half_lon * 0.6).min(MAX_SHARE_LAT_SPAN / 2.0).min(85.0);
|
||||
ShareBounds {
|
||||
south: lat - half_lat,
|
||||
north: lat + half_lat,
|
||||
west: lon - half_lon,
|
||||
east: lon + half_lon,
|
||||
south: (lat - half_lat).max(-90.0),
|
||||
north: (lat + half_lat).min(90.0),
|
||||
west: (lon - half_lon).max(-180.0),
|
||||
east: (lon + half_lon).min(180.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid_share_bounds(bounds: ShareBounds) -> bool {
|
||||
let values = [bounds.south, bounds.west, bounds.north, bounds.east];
|
||||
values.iter().all(|value| value.is_finite())
|
||||
&& bounds.south >= -90.0
|
||||
&& bounds.north <= 90.0
|
||||
&& bounds.west >= -180.0
|
||||
&& bounds.east <= 180.0
|
||||
&& bounds.south <= bounds.north
|
||||
&& bounds.west <= bounds.east
|
||||
}
|
||||
|
||||
/// Check whether the user is allowed to query data at the given bounds.
|
||||
/// Licensed users and admins bypass the check entirely.
|
||||
/// Free/anonymous users get 403 unless the bounds fall inside the free zone
|
||||
|
|
@ -224,3 +276,73 @@ pub fn check_license_point(
|
|||
) -> Result<(), axum::response::Response> {
|
||||
check_license_bounds(user, (lat, lon, lat, lon), share_bounds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_close(actual: f64, expected: f64) {
|
||||
assert!(
|
||||
(actual - expected).abs() < 1e-9,
|
||||
"expected {actual} to be close to {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_creation_clamps_over_broad_view_to_center() {
|
||||
let (params, bounds) =
|
||||
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=4.2&filter=price%3A1%3A2")
|
||||
.unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
assert!(parsed.contains(&("zoom".to_string(), "11.0".to_string())));
|
||||
assert!(parsed.contains(&("filter".to_string(), "price:1:2".to_string())));
|
||||
assert_close((bounds.south + bounds.north) / 2.0, 51.5);
|
||||
assert_close((bounds.west + bounds.east) / 2.0, -0.1);
|
||||
assert!(bounds.north - bounds.south <= MAX_SHARE_LAT_SPAN);
|
||||
assert!(bounds.east - bounds.west <= MAX_SHARE_LON_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_creation_keeps_specific_zoom_inside_limit() {
|
||||
let (params, bounds) =
|
||||
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=13.3").unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
assert!(parsed.contains(&("zoom".to_string(), "13.3".to_string())));
|
||||
assert!(bounds.north - bounds.south < MAX_SHARE_LAT_SPAN);
|
||||
assert!(bounds.east - bounds.west < MAX_SHARE_LON_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_consumption_accepts_bounds_larger_than_creation_limit() {
|
||||
assert!(is_valid_share_bounds(ShareBounds {
|
||||
south: 40.0,
|
||||
west: -10.0,
|
||||
north: 60.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_consumption_still_rejects_malformed_bounds() {
|
||||
assert!(!is_valid_share_bounds(ShareBounds {
|
||||
south: 60.0,
|
||||
west: -10.0,
|
||||
north: 40.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
assert!(!is_valid_share_bounds(ShareBounds {
|
||||
south: 40.0,
|
||||
west: -181.0,
|
||||
north: 60.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use axum::middleware::Next;
|
|||
use axum::response::Response;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::language::{language_from_accept_language, query_string_with_language};
|
||||
use crate::state::AppState;
|
||||
|
||||
const OG_PLACEHOLDER: &str =
|
||||
|
|
@ -140,19 +141,19 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
|||
"/saved" => Some(SeoPage {
|
||||
canonical_path: "/saved",
|
||||
title: "Perfect Postcode account",
|
||||
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
|
||||
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
||||
indexable: false,
|
||||
}),
|
||||
"/invites" => Some(SeoPage {
|
||||
canonical_path: "/invites",
|
||||
title: "Perfect Postcode account",
|
||||
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
|
||||
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
||||
indexable: false,
|
||||
}),
|
||||
"/account" => Some(SeoPage {
|
||||
canonical_path: "/account",
|
||||
title: "Perfect Postcode account",
|
||||
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
|
||||
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
||||
indexable: false,
|
||||
}),
|
||||
_ if path.starts_with("/invite/") => Some(SeoPage {
|
||||
|
|
@ -223,9 +224,17 @@ fn not_found_response(public_url: &str, path: &str) -> Response {
|
|||
response
|
||||
}
|
||||
|
||||
fn route_seo_tags(page: &SeoPage, path: &str, query_string: &str, public_url: &str) -> String {
|
||||
fn route_seo_tags(
|
||||
page: &SeoPage,
|
||||
path: &str,
|
||||
query_string: &str,
|
||||
public_url: &str,
|
||||
language: &str,
|
||||
) -> 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);
|
||||
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);
|
||||
let title_e = escape_attr(page.title);
|
||||
|
|
@ -233,15 +242,15 @@ fn route_seo_tags(page: &SeoPage, path: &str, query_string: &str, public_url: &s
|
|||
|
||||
let is_invite = path.starts_with("/invite/");
|
||||
let og_image_url = if is_invite {
|
||||
if query_string.is_empty() {
|
||||
if screenshot_query_string.is_empty() {
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}")
|
||||
} else {
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}&{query_e}")
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}&{screenshot_query_e}")
|
||||
}
|
||||
} else if query_string.is_empty() {
|
||||
} else if screenshot_query_string.is_empty() {
|
||||
format!("{public_url_e}/api/screenshot?og=1")
|
||||
} else {
|
||||
format!("{public_url_e}/api/screenshot?og=1&{query_e}")
|
||||
format!("{public_url_e}/api/screenshot?og=1&{screenshot_query_e}")
|
||||
};
|
||||
|
||||
let canonical_url = format!("{public_url_e}{canonical_path_e}");
|
||||
|
|
@ -313,6 +322,12 @@ 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
|
||||
let query_string = request.uri().query().unwrap_or("").to_string();
|
||||
let language = language_from_accept_language(
|
||||
request
|
||||
.headers()
|
||||
.get(header::ACCEPT_LANGUAGE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
|
||||
// Get state from extensions
|
||||
let state = request.extensions().get::<Arc<AppState>>().cloned();
|
||||
|
|
@ -362,7 +377,7 @@ 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);
|
||||
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url, language);
|
||||
let html = inject_tags(html, &page, &tags);
|
||||
parts.headers.remove(header::CONTENT_LENGTH);
|
||||
Response::from_parts(parts, Body::from(html))
|
||||
|
|
|
|||
|
|
@ -80,6 +80,28 @@ pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCod
|
|||
|
||||
let (south, west, north, east) = (parts[0], parts[1], parts[2], parts[3]);
|
||||
|
||||
if ![south, west, north, east]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid bounds: values must be finite numbers".into(),
|
||||
));
|
||||
}
|
||||
if !(-90.0..=90.0).contains(&south) || !(-90.0..=90.0).contains(&north) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid bounds: latitude must be between -90 and 90".into(),
|
||||
));
|
||||
}
|
||||
if !(-180.0..=180.0).contains(&west) || !(-180.0..=180.0).contains(&east) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid bounds: longitude must be between -180 and 180".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate that bounds are not inverted
|
||||
if south > north {
|
||||
return Err((
|
||||
|
|
@ -112,8 +134,8 @@ mod tests {
|
|||
(1.0, 2.0, 3.0, 4.0)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_bounds("-51.5, -0.1, 51.6, 0.2").unwrap(),
|
||||
(-51.5, -0.1, 51.6, 0.2)
|
||||
parse_bounds("51.5, -0.1, 51.6, 0.2").unwrap(),
|
||||
(51.5, -0.1, 51.6, 0.2)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +155,18 @@ mod tests {
|
|||
assert!(parse_bounds("51.0,0.5,52.0,-0.5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_rejects_non_finite_values() {
|
||||
assert!(parse_bounds("NaN,0,1,1").is_err());
|
||||
assert!(parse_bounds("0,0,inf,1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_accepts_world_sized_bounds() {
|
||||
assert!(parse_bounds("-90,-180,90,180").is_ok());
|
||||
assert!(parse_bounds("35.8,-45.0,67.2,45.0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_applies_buffer() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
|
|
|
|||
|
|
@ -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