use std::sync::{Arc, LazyLock}; use std::time::Duration; use axum::body::Body; use axum::extract::Request; use axum::http::{HeaderName, StatusCode}; use axum::response::{IntoResponse, Response}; use tracing::warn; use crate::state::AppState; /// 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 /// PocketBase realtime/OAuth2 are long-lived streams. static PROXY_CLIENT: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(5)) .referer(false) .build() .expect("Failed to build proxy HTTP client") }); pub async fn proxy_to_pocketbase(state: Arc, req: Request) -> impl IntoResponse { let pb_url = state.pocketbase_url.trim_end_matches('/'); let path = req.uri().path(); let target_path = path.strip_prefix("/pb").unwrap_or(path); let query = req .uri() .query() .map(|qs| format!("?{qs}")) .unwrap_or_default(); let url = format!("{pb_url}{target_path}{query}"); let method = req.method().clone(); let mut builder = PROXY_CLIENT.request(method, &url); // Forward only safe headers (allowlist) const ALLOWED_HEADERS: &[&str] = &[ "content-type", "accept", "authorization", "cookie", "accept-language", ]; for (name, value) in req.headers() { if ALLOWED_HEADERS.contains(&name.as_str()) { builder = builder.header(name.clone(), value.clone()); } } // 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()); } // Forward body let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await { Ok(bytes) => bytes, Err(err) => { warn!("Failed to read request body: {err}"); return Response::builder() .status(StatusCode::BAD_REQUEST) .body(Body::from("Failed to read request body")) .unwrap(); } }; builder = builder.body(body_bytes); match builder.send().await { Ok(upstream) => { let status = upstream.status(); let mut response = Response::builder().status(status); for (name, value) in upstream.headers() { // Skip hop-by-hop headers if name == "transfer-encoding" { continue; } match HeaderName::from_bytes(name.as_ref()) { Ok(header_name) => { response = response.header(header_name, value.clone()); } Err(err) => { warn!(header = ?name, error = %err, "Skipping unparseable upstream header"); } } } // Stream the response body instead of buffering it entirely. // This is critical for SSE (Server-Sent Events) used by PocketBase's // realtime system and OAuth2 flow — buffering would hang forever // since SSE responses never complete. let body = Body::from_stream(upstream.bytes_stream()); response.body(body).unwrap() } Err(err) => { warn!("PocketBase proxy error: {err}"); Response::builder() .status(StatusCode::BAD_GATEWAY) .body(Body::from("PocketBase unavailable")) .unwrap() } } }