One bounded HTTP client for every server-side fetch; uploads refused while streaming

Every outbound fetch - Gitea content at boot and on reload, the raw
asset proxy, the repo-info endpoint, the content-declared url resource
- built its own reqwest client with no timeout and read whole bodies
with .text(). `crate::http` is now the one client (5 s connect, 20 s
per request, 5 redirects) with capped body readers: 2 MB for a url
resource, 8 MB for a Gitea reply, 32 MB for a proxied asset; one byte
over is an error, never a truncated document.

The upload handler read a whole field into memory before comparing it
to the 20 MB cap, and axum's 2 MB default body limit had made that cap
unreachable anyway. It now reads chunk by chunk and refuses the moment
the cap is crossed; the /upload route raises the body limit to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-22 16:51:15 +02:00
co-authored by Claude Fable 5.1
parent d7f60eaa31
commit c91bf59946
5 changed files with 99 additions and 18 deletions
+11 -11
View File
@@ -260,21 +260,20 @@ async fn fetch_gitea_json(
for (k, v) in params {
url.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let mut req = client.get(url.clone());
let mut req = crate::http::client().get(url.clone());
if let Ok(token) = std::env::var("GITEA_API_TOKEN") {
if !token.is_empty() {
req = req.header("Authorization", format!("token {token}"));
}
}
let text = req
let response = req
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?
.text()
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?;
let text = crate::http::text(response, crate::http::GITEA_CAP)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(format!("reading {url}: {e}")))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}
@@ -341,16 +340,17 @@ async fn fetch_url_json(
for (k, v) in &leftover {
parsed.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let text = client
let response = crate::http::client()
.get(parsed.clone())
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?
.text()
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?;
// Content picks the URL; the cap is what keeps a runaway endpoint
// from becoming a runaway allocation here.
let text = crate::http::text(response, crate::http::URL_RESOURCE_CAP)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(format!("reading {parsed}: {e}")))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}