//! The one outbound HTTP client, bounded. Every server-side fetch - //! Gitea content at boot and on reload, the raw-asset proxy, the //! repo-info endpoint, and the content-declared `url` resource - goes //! through here, so a slow or oversized upstream can cost a request at //! most `TIMEOUT` and `cap` bytes, never a thread or the heap. Before //! this module each call site built its own client with no timeout and //! read whole bodies with `.text()`. #![cfg(feature = "ssr")] use std::sync::OnceLock; use std::time::Duration; use openidconnect::reqwest; /// Whole-request budget: connect, headers, and body. pub const TIMEOUT: Duration = Duration::from_secs(20); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); /// A content-declared URL resource: JSON for one card. pub const URL_RESOURCE_CAP: usize = 2 * 1024 * 1024; /// A Gitea API reply or one content file (a tree listing, a YAML page). pub const GITEA_CAP: usize = 8 * 1024 * 1024; /// A content-shipped asset proxied to the browser (an image, a font). pub const ASSET_CAP: usize = 32 * 1024 * 1024; pub fn client() -> &'static reqwest::Client { static CLIENT: OnceLock = OnceLock::new(); CLIENT.get_or_init(|| { reqwest::Client::builder() .connect_timeout(CONNECT_TIMEOUT) .timeout(TIMEOUT) .redirect(reqwest::redirect::Policy::limited(5)) .user_agent(concat!("portal/", env!("CARGO_PKG_VERSION"))) .build() .expect("a client with static options always builds") }) } /// Reads a body up to `cap` bytes; one byte more is an error, not a /// truncation, so a caller never parses half a document. pub async fn bytes(mut response: reqwest::Response, cap: usize) -> anyhow::Result> { let hint = response.content_length().unwrap_or(0) as usize; if hint > cap { anyhow::bail!("response of {hint} bytes exceeds the cap of {cap}"); } let mut out = Vec::with_capacity(hint.min(cap)); while let Some(chunk) = response.chunk().await? { if out.len() + chunk.len() > cap { anyhow::bail!("response exceeds the cap of {cap} bytes"); } out.extend_from_slice(&chunk); } Ok(out) } pub async fn text(response: reqwest::Response, cap: usize) -> anyhow::Result { Ok(String::from_utf8(bytes(response, cap).await?)?) } /// `GET url`, status checked, body read under `cap`. pub async fn get_text(url: &str, cap: usize) -> anyhow::Result { let response = client().get(url).send().await?.error_for_status()?; text(response, cap).await }