diff --git a/fluxer_app_proxy/Dockerfile b/fluxer_app_proxy/Dockerfile index 3da09828d..83675ce67 100644 --- a/fluxer_app_proxy/Dockerfile +++ b/fluxer_app_proxy/Dockerfile @@ -84,6 +84,7 @@ FROM alpine:3.21 AS app-assets ARG BUNDLE_LOCAL_ASSETS=true ARG PUBLIC_ASSET_BASE_URL="" COPY --from=app-build /usr/src/app/fluxer_app/dist /assets +COPY fluxer_app_proxy/scripts/precompress_assets.sh /usr/local/bin/precompress_assets.sh RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \ case "${PUBLIC_ASSET_BASE_URL}" in \ http://* | https://*) ;; \ @@ -95,6 +96,8 @@ RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \ esac; \ find /assets/assets -type f ! -name 'fonts-*.txt' -delete; \ fi +RUN apk add --no-cache brotli \ + && /usr/local/bin/precompress_assets.sh /assets # Stage for CI to extract the full dist (docker bake app-dist target) FROM alpine:3.21 AS app-dist diff --git a/fluxer_app_proxy/scripts/precompress_assets.sh b/fluxer_app_proxy/scripts/precompress_assets.sh new file mode 100755 index 000000000..736b139f5 --- /dev/null +++ b/fluxer_app_proxy/scripts/precompress_assets.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env sh +# SPDX-License-Identifier: AGPL-3.0-or-later + +set -eu + +if [ "${PRECOMPRESS_WORKER:-}" = "1" ]; then + source_file="$1" + source_size="$(wc -c <"$source_file")" + + if brotli -q "$PRECOMPRESS_BROTLI_QUALITY" -f -o "$source_file.br" "$source_file" 2>/dev/null; then + if [ "$(wc -c <"$source_file.br")" -ge "$source_size" ]; then + rm -f "$source_file.br" + fi + else + rm -f "$source_file.br" + fi + + if gzip -n "-$PRECOMPRESS_GZIP_LEVEL" -c "$source_file" >"$source_file.gz" 2>/dev/null; then + if [ "$(wc -c <"$source_file.gz")" -ge "$source_size" ]; then + rm -f "$source_file.gz" + fi + else + rm -f "$source_file.gz" + fi + + exit 0 +fi + +root="${1:?usage: precompress_assets.sh }" + +case "$0" in +/*) script="$0" ;; +*) script="$PWD/$0" ;; +esac + +PRECOMPRESS_WORKER=1 +PRECOMPRESS_BROTLI_QUALITY="${PRECOMPRESS_BROTLI_QUALITY:-11}" +PRECOMPRESS_GZIP_LEVEL="${PRECOMPRESS_GZIP_LEVEL:-9}" +export PRECOMPRESS_WORKER PRECOMPRESS_BROTLI_QUALITY PRECOMPRESS_GZIP_LEVEL + +min_size="${PRECOMPRESS_MIN_SIZE_BYTES:-1024}" +jobs="${PRECOMPRESS_JOBS:-$(nproc 2>/dev/null || echo 1)}" + +find "$root" -type f \ + ! -name '*.br' \ + ! -name '*.gz' \ + -size +"${min_size}"c \ + \( \ + -name '*.css' -o \ + -name '*.html' -o \ + -name '*.js' -o \ + -name '*.json' -o \ + -name '*.map' -o \ + -name '*.mjs' -o \ + -name '*.svg' -o \ + -name '*.txt' -o \ + -name '*.wasm' -o \ + -name '*.webmanifest' -o \ + -name '*.xml' \ + \) \ + -print0 | + xargs -0 -r -n 1 -P "$jobs" "$script" diff --git a/fluxer_app_proxy/src/main.rs b/fluxer_app_proxy/src/main.rs index 6aaf79b1a..3a26b506a 100644 --- a/fluxer_app_proxy/src/main.rs +++ b/fluxer_app_proxy/src/main.rs @@ -2,8 +2,12 @@ use anyhow::Context; use fluxer_app_proxy::{ - config::AppProxyConfig, discovery_cache::DiscoveryCache, geoip, - invite_meta::InviteMetaResolver, routes::build_router, state::AppState, + config::AppProxyConfig, + discovery_cache::DiscoveryCache, + geoip, + invite_meta::InviteMetaResolver, + routes::build_router, + state::{AppState, build_http_client}, }; use std::sync::Arc; use tokio::{net::TcpListener, runtime::Builder}; @@ -28,12 +32,8 @@ fn main() -> anyhow::Result<()> { .context("failed to create Fluxer app proxy async runtime")?; runtime.block_on(async move { - let http_client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(5)) - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::limited(2)) - .build() - .context("failed to build Fluxer app proxy HTTP client")?; + let http_client = + build_http_client().context("failed to build Fluxer app proxy HTTP client")?; let discovery_cache = Arc::new(DiscoveryCache::new()); if let Err(err) = discovery_cache diff --git a/fluxer_app_proxy/src/routes/assets_proxy.rs b/fluxer_app_proxy/src/routes/assets_proxy.rs index 31c75a7e3..b2ea91856 100644 --- a/fluxer_app_proxy/src/routes/assets_proxy.rs +++ b/fluxer_app_proxy/src/routes/assets_proxy.rs @@ -8,13 +8,14 @@ use axum::{ http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; -use std::path::Path as FsPath; +use std::path::{Path as FsPath, PathBuf}; use std::time::Duration; use super::file_stream::stream_file; use super::spa_static::{CORS_ALLOW_ANY_VALUE, asset_cache_control, guess_mime, is_font_mime}; const ASSET_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const PRECOMPRESSED_VARIANTS: &[(&str, &str)] = &[("br", "br"), ("gzip", "gz")]; const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024; const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store"; const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[ @@ -26,7 +27,6 @@ const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[ ]; const BLOCKED_REQUEST_HEADERS: &[&str] = &[ - "accept-encoding", "authorization", "connection", "cookie", @@ -118,14 +118,12 @@ pub async fn proxy_assets( if BLOCKED_RESPONSE_HEADERS.contains(&name_str) { continue; } - if name_str == "content-encoding" || name_str == "content-length" { - continue; - } response_headers.insert(name.clone(), value.clone()); } set_known_asset_content_type(&mut response_headers, &path); set_font_cors(&mut response_headers); set_proxied_cache_control(&mut response_headers, &path, status); + set_vary_on_accept_encoding(&mut response_headers); let asset_csp = build_asset_csp( &state.config.csp, @@ -169,7 +167,10 @@ pub(super) async fn serve_local_asset( return StatusCode::NOT_FOUND.into_response(); } - let entity_tag = tokio::fs::metadata(&resolved) + let (served_path, content_encoding) = + select_precompressed_variant(&resolved, &base, request_headers).await; + + let entity_tag = tokio::fs::metadata(&served_path) .await .ok() .and_then(|metadata| local_asset_entity_tag(&metadata)); @@ -182,7 +183,8 @@ pub(super) async fn serve_local_asset( return response; } - let mut response = match stream_file(&resolved, request_headers, entity_tag.as_deref()).await { + let mut response = match stream_file(&served_path, request_headers, entity_tag.as_deref()).await + { Ok(response) => response, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return StatusCode::NOT_FOUND.into_response(); @@ -197,15 +199,82 @@ pub(super) async fn serve_local_asset( if let Ok(value) = HeaderValue::from_str(mime_type) { response.headers_mut().insert(header::CONTENT_TYPE, value); } + if let Some(content_encoding) = content_encoding { + response.headers_mut().insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(content_encoding), + ); + } set_local_asset_headers(response.headers_mut(), relative_path, entity_tag.as_deref()); response } +async fn select_precompressed_variant( + resolved: &FsPath, + base: &FsPath, + request_headers: &HeaderMap, +) -> (PathBuf, Option<&'static str>) { + for &(encoding, extension) in PRECOMPRESSED_VARIANTS { + if !accepts_encoding(request_headers, encoding) { + continue; + } + let Some(candidate) = usable_sibling(resolved, base, extension).await else { + continue; + }; + return (candidate, Some(encoding)); + } + (resolved.to_path_buf(), None) +} + +async fn usable_sibling(resolved: &FsPath, base: &FsPath, extension: &str) -> Option { + let mut name = resolved.as_os_str().to_owned(); + name.push("."); + name.push(extension); + + let candidate = tokio::fs::canonicalize(PathBuf::from(name)).await.ok()?; + if !candidate.starts_with(base) { + return None; + } + tokio::fs::metadata(&candidate) + .await + .ok() + .filter(std::fs::Metadata::is_file) + .map(|_| candidate) +} + +fn accepts_encoding(headers: &HeaderMap, encoding: &str) -> bool { + let Some(header_value) = headers + .get(header::ACCEPT_ENCODING) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + header_value.split(',').any(|candidate| { + let mut parts = candidate.split(';').map(str::trim); + let Some(name) = parts.next() else { + return false; + }; + name.eq_ignore_ascii_case(encoding) && !parts.any(is_zero_quality) + }) +} + +fn is_zero_quality(parameter: &str) -> bool { + let Some((key, value)) = parameter.split_once('=') else { + return false; + }; + key.trim().eq_ignore_ascii_case("q") + && value + .trim() + .parse::() + .is_ok_and(|quality| quality <= 0.0) +} + fn set_local_asset_headers(headers: &mut HeaderMap, relative_path: &str, entity_tag: Option<&str>) { headers.insert( header::CACHE_CONTROL, HeaderValue::from_static(asset_cache_control(relative_path)), ); + set_vary_on_accept_encoding(headers); if is_font_mime(guess_mime(relative_path)) { headers.insert( header::ACCESS_CONTROL_ALLOW_ORIGIN, @@ -258,6 +327,20 @@ fn set_proxied_cache_control(headers: &mut HeaderMap, path: &str, status: Status } } +fn set_vary_on_accept_encoding(headers: &mut HeaderMap) { + let already_varies = headers.get_all(header::VARY).iter().any(|value| { + value.to_str().is_ok_and(|value| { + value.split(',').any(|field| { + let field = field.trim(); + field == "*" || field.eq_ignore_ascii_case("accept-encoding") + }) + }) + }); + if !already_varies { + headers.append(header::VARY, HeaderValue::from_static("accept-encoding")); + } +} + fn set_font_cors(headers: &mut HeaderMap) { let is_font = headers .get(header::CONTENT_TYPE) @@ -290,12 +373,14 @@ mod tests { use super::*; use crate::config::AppProxyConfig; use crate::discovery_cache::DiscoveryCache; + use crate::state::build_http_client; use axum::Router; use axum::http::Request as HttpRequest; use axum::http::header::HeaderName; use fluxer_common::config::GeoipSourceConfig; use fluxer_common::geoip::{GeoipConfig, GeoipResolver}; use std::sync::Arc; + use tower::ServiceExt; async fn spawn_upstream(status: StatusCode, cache_control: &'static str) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -318,9 +403,20 @@ mod tests { fn upstream_backed_state(cdn_endpoint: &str) -> AppState { let mut config = AppProxyConfig::from_env(); config.static_cdn_endpoint = Some(cdn_endpoint.to_owned()); + state_from_config(config) + } + + fn locally_backed_state(static_dir: &str) -> AppState { + let mut config = AppProxyConfig::from_env(); + config.static_cdn_endpoint = None; + config.static_dir = static_dir.to_owned(); + state_from_config(config) + } + + fn state_from_config(config: AppProxyConfig) -> AppState { AppState { config: Arc::new(config), - http_client: reqwest::Client::new(), + http_client: build_http_client().unwrap(), discovery_cache: Arc::new(DiscoveryCache::new()), geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig { geoip_source: GeoipSourceConfig::Filesystem { @@ -543,6 +639,11 @@ mod tests { Self { root } } + fn and_sibling(self, name: &str, bytes: &[u8]) -> Self { + std::fs::write(self.root.join("assets").join(name), bytes).unwrap(); + self + } + fn dir(&self) -> &str { self.root.to_str().unwrap() } @@ -719,6 +820,365 @@ mod tests { assert!(is_hashed_asset("assets/2d715e4730758083.worker.js")); } + fn accept_encoding(value: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static(value)); + headers + } + + fn content_encoding_of(response: &Response) -> Option<&str> { + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + } + + fn varies_on_accept_encoding(response: &Response) -> bool { + response + .headers() + .get_all(header::VARY) + .iter() + .any(|value| { + value + .to_str() + .is_ok_and(|value| value.eq_ignore_ascii_case("accept-encoding")) + }) + } + + async fn body_bytes(response: Response) -> Vec { + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec() + } + + #[tokio::test] + async fn a_local_asset_is_served_from_its_precompressed_brotli_sibling() { + let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)") + .and_sibling("356aaade04a117b1.js.br", b"brotli-bytes"); + + let response = serve_local_asset( + fixture.dir(), + "assets/356aaade04a117b1.js", + &accept_encoding("gzip, deflate, br, zstd"), + ) + .await; + + assert_eq!(content_encoding_of(&response), Some("br")); + assert!(varies_on_accept_encoding(&response)); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/javascript; charset=utf-8"), + "the encoding must not leak into the media type the browser parses" + ); + assert_eq!( + body_bytes(response).await, + b"brotli-bytes", + "the sibling produced at build time must reach the wire unmodified" + ); + } + + #[tokio::test] + async fn a_local_asset_falls_back_to_the_raw_file_without_a_sibling() { + let fixture = LocalAssetDir::with_asset("469e0b8f10c496a1.css", b"body{color:red}"); + + let response = serve_local_asset( + fixture.dir(), + "assets/469e0b8f10c496a1.css", + &accept_encoding("gzip, deflate, br"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(content_encoding_of(&response), None); + assert!(varies_on_accept_encoding(&response)); + assert_eq!(body_bytes(response).await, b"body{color:red}"); + } + + #[tokio::test] + async fn a_local_asset_only_uses_an_encoding_the_client_accepted() { + let fixture = LocalAssetDir::with_asset("488b87159423ca35.js", b"console.log(2)") + .and_sibling("488b87159423ca35.js.br", b"brotli-bytes") + .and_sibling("488b87159423ca35.js.gz", b"gzip-bytes"); + + let gzip_only = serve_local_asset( + fixture.dir(), + "assets/488b87159423ca35.js", + &accept_encoding("gzip, deflate"), + ) + .await; + assert_eq!(content_encoding_of(&gzip_only), Some("gzip")); + assert_eq!(body_bytes(gzip_only).await, b"gzip-bytes"); + + let identity = serve_local_asset( + fixture.dir(), + "assets/488b87159423ca35.js", + &HeaderMap::new(), + ) + .await; + assert_eq!( + content_encoding_of(&identity), + None, + "a client that advertised no encoding cannot decode the sibling" + ); + assert_eq!(body_bytes(identity).await, b"console.log(2)"); + } + + #[tokio::test] + async fn a_local_asset_refuses_a_sibling_the_client_scored_zero() { + let fixture = LocalAssetDir::with_asset("2d715e4730758083.worker.js", b"self.onmessage=0") + .and_sibling("2d715e4730758083.worker.js.br", b"brotli-bytes"); + + let response = serve_local_asset( + fixture.dir(), + "assets/2d715e4730758083.worker.js", + &accept_encoding("br;q=0, gzip"), + ) + .await; + + assert_eq!(content_encoding_of(&response), None); + assert_eq!(body_bytes(response).await, b"self.onmessage=0"); + } + + #[tokio::test] + async fn a_precompressed_variant_carries_its_own_validator() { + let fixture = LocalAssetDir::with_asset("f00dcafe12345678.css", b"body{}") + .and_sibling("f00dcafe12345678.css.br", b"brotli-bytes-are-longer"); + + let brotli = serve_local_asset( + fixture.dir(), + "assets/f00dcafe12345678.css", + &accept_encoding("br"), + ) + .await; + let brotli_tag = entity_tag_of(&brotli).expect("the brotli variant carries a validator"); + + let identity = serve_local_asset( + fixture.dir(), + "assets/f00dcafe12345678.css", + &HeaderMap::new(), + ) + .await; + let identity_tag = entity_tag_of(&identity).expect("the raw file carries a validator"); + + assert_ne!( + brotli_tag, identity_tag, + "two encodings sharing one validator let a cache hand brotli to a client that asked for identity" + ); + + let mut conditional = accept_encoding("br"); + conditional.insert( + header::IF_NONE_MATCH, + HeaderValue::from_str(&brotli_tag).unwrap(), + ); + let revalidated = + serve_local_asset(fixture.dir(), "assets/f00dcafe12345678.css", &conditional).await; + assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED); + assert!(varies_on_accept_encoding(&revalidated)); + } + + #[tokio::test] + async fn a_range_over_a_precompressed_sibling_describes_the_encoded_bytes() { + let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)") + .and_sibling("356aaade04a117b1.js.br", b"0123456789"); + + let mut ranged = accept_encoding("br"); + ranged.insert(header::RANGE, HeaderValue::from_static("bytes=4-6")); + let response = + serve_local_asset(fixture.dir(), "assets/356aaade04a117b1.js", &ranged).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(content_encoding_of(&response), Some("br")); + assert_eq!( + response + .headers() + .get(header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()), + Some("bytes 4-6/10"), + "a range counted over the raw file cannot be reassembled from the encoded bytes we sent" + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()), + Some("3") + ); + assert_eq!(body_bytes(response).await, b"456"); + } + + async fn spawn_encoded_upstream(content_encoding: &'static str, body: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = Router::new().fallback(move |request: HttpRequest| async move { + let echoed = request + .headers() + .get(header::ACCEPT_ENCODING) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_owned(); + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(content_encoding), + ); + response.headers_mut().insert( + HeaderName::from_static("x-echoed-accept-encoding"), + HeaderValue::from_str(&echoed).unwrap(), + ); + response + }); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn a_cdn_backed_asset_streams_the_upstream_encoding_untouched() { + let endpoint = spawn_encoded_upstream("br", "already-brotli").await; + let state = upstream_backed_state(&endpoint); + let request = HttpRequest::builder() + .uri("/assets/356aaade04a117b1.js") + .header(header::ACCEPT_ENCODING, "gzip, deflate, br") + .body(Body::empty()) + .unwrap(); + + let response = proxy_assets( + State(state), + Path("356aaade04a117b1.js".to_owned()), + request, + ) + .await; + + assert_eq!( + response + .headers() + .get("x-echoed-accept-encoding") + .and_then(|value| value.to_str().ok()), + Some("gzip, deflate, br"), + "blocking accept-encoding forces the origin to hand us bytes it already had compressed" + ); + assert_eq!( + content_encoding_of(&response), + Some("br"), + "dropping content-encoding turns compressed upstream bytes into an undecodable body" + ); + assert!(varies_on_accept_encoding(&response)); + assert_eq!(body_bytes(response).await, b"already-brotli"); + } + + #[tokio::test] + async fn a_cdn_backed_asset_keeps_the_upstream_content_length() { + let endpoint = spawn_encoded_upstream("gzip", "0123456789").await; + let state = upstream_backed_state(&endpoint); + let request = HttpRequest::builder() + .uri("/assets/voice_engine_bg.wasm") + .header(header::ACCEPT_ENCODING, "gzip") + .body(Body::empty()) + .unwrap(); + + let response = proxy_assets( + State(state), + Path("voice_engine_bg.wasm".to_owned()), + request, + ) + .await; + + assert_eq!( + response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()), + Some("10"), + "a client that cannot see the encoded length cannot show download progress" + ); + } + + const COMPRESSIBLE_BODY: &[u8] = + b"the default compression predicate ignores anything under thirty-two bytes"; + + #[tokio::test] + async fn an_asset_without_a_sibling_is_still_compressed_before_it_leaves() { + let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", COMPRESSIBLE_BODY); + let router = super::super::build_router(locally_backed_state(fixture.dir())); + + let asset = router + .oneshot( + HttpRequest::builder() + .uri("/assets/356aaade04a117b1.js") + .header(header::ACCEPT_ENCODING, "gzip") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(asset.status(), StatusCode::OK); + assert_eq!( + content_encoding_of(&asset), + Some("gzip"), + "an extension the build-time step does not cover must not fall off a bandwidth cliff" + ); + assert_ne!(body_bytes(asset).await, COMPRESSIBLE_BODY); + } + + #[tokio::test] + async fn a_passed_through_cdn_encoding_is_never_recompressed_by_the_layer() { + let endpoint = spawn_encoded_upstream( + "br", + "already brotli, and long enough to clear the thirty-two byte floor", + ) + .await; + let router = super::super::build_router(upstream_backed_state(&endpoint)); + + let response = router + .oneshot( + HttpRequest::builder() + .uri("/assets/356aaade04a117b1.js") + .header(header::ACCEPT_ENCODING, "gzip, deflate, br") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(content_encoding_of(&response), Some("br")); + assert_eq!( + body_bytes(response).await, + b"already brotli, and long enough to clear the thirty-two byte floor", + "re-encoding upstream bytes that already carry an encoding breaks every browser" + ); + } + + #[tokio::test] + async fn a_precompressed_sibling_reaches_the_client_through_the_router() { + let fixture = LocalAssetDir::with_asset("488b87159423ca35.js", COMPRESSIBLE_BODY) + .and_sibling("488b87159423ca35.js.br", b"brotli-bytes"); + let router = super::super::build_router(locally_backed_state(fixture.dir())); + + let response = router + .oneshot( + HttpRequest::builder() + .uri("/assets/488b87159423ca35.js") + .header(header::ACCEPT_ENCODING, "gzip, deflate, br") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(content_encoding_of(&response), Some("br")); + assert_eq!( + body_bytes(response).await, + b"brotli-bytes", + "re-encoding the sibling would double-compress it and break every browser" + ); + } + #[test] fn known_js_asset_overrides_upstream_octet_stream() { let mut headers = HeaderMap::new(); diff --git a/fluxer_app_proxy/src/state.rs b/fluxer_app_proxy/src/state.rs index dee5d509c..a5cf20b25 100644 --- a/fluxer_app_proxy/src/state.rs +++ b/fluxer_app_proxy/src/state.rs @@ -5,6 +5,7 @@ use crate::discovery_cache::DiscoveryCache; use crate::invite_meta::InviteMetaResolver; use fluxer_common::geoip::GeoipResolver; use std::sync::Arc; +use std::time::Duration; #[derive(Clone)] pub struct AppState { @@ -15,3 +16,14 @@ pub struct AppState { pub invite_meta: Option>, pub index_html: Option>, } + +pub fn build_http_client() -> reqwest::Result { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::limited(2)) + .no_gzip() + .no_brotli() + .no_deflate() + .build() +}