perfect-postcode/server-rs/src/routes/pb_proxy.rs
2026-03-08 21:29:15 +00:00

117 lines
4.3 KiB
Rust

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<reqwest::Client> = 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<AppState>, 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()
}
}
}