mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
perf(media-proxy): cache-probe and stream external fetches (#2168)
This commit is contained in:
+476
-121
@@ -28,14 +28,17 @@ use axum::{
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
use http_body::Frame;
|
||||
use http_body_util::{BodyExt, Limited};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
pin::Pin,
|
||||
sync::{Arc, OnceLock},
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
@@ -48,6 +51,7 @@ struct AppState {
|
||||
client: http_client::HttpClient,
|
||||
nsfw_client: reqwest::Client,
|
||||
transform_cache: Arc<Cache>,
|
||||
external_hints: ExternalHintCache,
|
||||
coalescer: Arc<ByteCoalescer>,
|
||||
native_transform_admissions: TimedSemaphore,
|
||||
native_transforms: TimedSemaphore,
|
||||
@@ -75,6 +79,7 @@ pub async fn run(cfg: Config) -> anyhow::Result<()> {
|
||||
cfg.transform_cache_max_entry_bytes,
|
||||
cfg.transform_cache_ttl_ms,
|
||||
)),
|
||||
external_hints: new_external_hint_cache(&cfg),
|
||||
coalescer: Arc::new(ByteCoalescer::new()),
|
||||
native_transform_admissions: TimedSemaphore::new(transform_admission_capacity(&cfg)),
|
||||
native_transforms: TimedSemaphore::new(cfg.max_native_transforms),
|
||||
@@ -996,6 +1001,164 @@ fn asset_filename_hint(asset: &ParsedAssetPath) -> String {
|
||||
format!("{hash}.{}", asset.original_ext.name())
|
||||
}
|
||||
|
||||
const EXTERNAL_SNIFF_PREFIX_BYTES: usize = 8192;
|
||||
const EXTERNAL_HINT_CACHE_ENTRIES: u64 = 4096;
|
||||
|
||||
type ExternalHintCache = moka::sync::Cache<String, ExternalHint>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExternalHint {
|
||||
url: String,
|
||||
content_type: String,
|
||||
source_format: Option<AssetExtension>,
|
||||
}
|
||||
|
||||
fn new_external_hint_cache(cfg: &Config) -> ExternalHintCache {
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(EXTERNAL_HINT_CACHE_ENTRIES)
|
||||
.time_to_live(Duration::from_millis(cfg.transform_cache_ttl_ms.max(1)))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn external_sniffed_content_type(data: &[u8], filename: &str, content_type: String) -> String {
|
||||
let prefix = &data[..data.len().min(EXTERNAL_SNIFF_PREFIX_BYTES)];
|
||||
if mime::sniff(prefix).mime == "image/svg+xml" {
|
||||
return "image/svg+xml".to_owned();
|
||||
}
|
||||
if content_type_is_trustworthy(&content_type) {
|
||||
return content_type;
|
||||
}
|
||||
mime::detect(prefix, filename, Some(&content_type))
|
||||
}
|
||||
|
||||
fn external_hint(url: &str, filename: &str, content_type: &str, data: &[u8]) -> ExternalHint {
|
||||
let content_type = external_sniffed_content_type(data, filename, content_type.to_owned());
|
||||
let source_format = if extension_from_mime(mime::sniff(data).mime) == Some(AssetExtension::Apng)
|
||||
{
|
||||
Some(AssetExtension::Apng)
|
||||
} else {
|
||||
extension_from_mime(&content_type).or_else(|| image_extension_from_filename(filename))
|
||||
};
|
||||
ExternalHint {
|
||||
url: url.to_owned(),
|
||||
content_type,
|
||||
source_format,
|
||||
}
|
||||
}
|
||||
|
||||
fn external_cached_transform(
|
||||
app: &Arc<AppState>,
|
||||
url: &str,
|
||||
method: &Method,
|
||||
params: &HashMap<String, String>,
|
||||
headers: &HeaderMap,
|
||||
) -> Option<Response> {
|
||||
let hint = app.external_hints.get(url)?;
|
||||
let explicit_requested_format = explicit_output_format(params).ok()?;
|
||||
let width = parse_optional_dimension_param(params, "width").ok()?;
|
||||
let height = parse_optional_dimension_param(params, "height").ok()?;
|
||||
let animated = animated_param(params, false);
|
||||
let filename = url_filename(&hint.url);
|
||||
let (format, quality, response_content_type) = match mime::category(&hint.content_type) {
|
||||
Some(mime::Category::Video) => {
|
||||
let format = output_format::coerce_unsupported_format(explicit_requested_format?);
|
||||
let quality = params
|
||||
.get("quality")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "high".to_owned());
|
||||
(format, quality, format.mime().to_owned())
|
||||
}
|
||||
Some(mime::Category::Image) => {
|
||||
let requested_format = explicit_requested_format.unwrap_or_else(|| {
|
||||
external_default_output_extension(&filename, &hint.content_type)
|
||||
});
|
||||
let requested_supported_format =
|
||||
output_format::coerce_unsupported_format(requested_format);
|
||||
let format = effective_animated_image_output_format(
|
||||
hint.source_format,
|
||||
requested_supported_format,
|
||||
animated,
|
||||
);
|
||||
let quality = params.get("quality").cloned().unwrap_or_else(|| {
|
||||
default_transform_quality(
|
||||
format,
|
||||
animated,
|
||||
transform_static_quality_default(hint.source_format),
|
||||
)
|
||||
.to_owned()
|
||||
});
|
||||
let response_content_type = transform_response_content_type(
|
||||
explicit_requested_format,
|
||||
requested_format,
|
||||
format,
|
||||
&hint.content_type,
|
||||
)
|
||||
.to_owned();
|
||||
(format, quality, response_content_type)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let cached = app
|
||||
.transform_cache
|
||||
.get(&transform_cache_key(TransformCacheKeyInput {
|
||||
route: TransformRoute::External,
|
||||
cache_identity: &hint.url,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
quality: &quality,
|
||||
animated,
|
||||
effort: None,
|
||||
}))?;
|
||||
metrics::GLOBAL
|
||||
.transform_cache_hits
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let disposition = content_disposition_header(
|
||||
&response_content_type,
|
||||
bool_param(params, "download", false),
|
||||
Some(&filename),
|
||||
);
|
||||
Some(media_response(
|
||||
method.clone(),
|
||||
cached,
|
||||
&response_content_type,
|
||||
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
||||
Some(disposition),
|
||||
))
|
||||
}
|
||||
|
||||
async fn external_head_passthrough(
|
||||
app: &AppState,
|
||||
url: &str,
|
||||
params: &HashMap<String, String>,
|
||||
) -> Option<Response> {
|
||||
let fetched = fetch_external_head(app, url).await.ok()?;
|
||||
if fetched.status != StatusCode::OK || is_svg_content_type(&fetched.content_type) {
|
||||
return None;
|
||||
}
|
||||
let total_len = usize::try_from(fetched.content_length?).ok()?;
|
||||
if total_len > constants::MAX_MEDIA_PROXY_BYTES {
|
||||
return None;
|
||||
}
|
||||
let filename = url_filename(&fetched.url);
|
||||
let content_type = if content_type_is_trustworthy(&fetched.content_type) {
|
||||
fetched.content_type
|
||||
} else {
|
||||
mime::detect(&[], &filename, Some(&fetched.content_type))
|
||||
};
|
||||
let disposition = content_disposition_header(
|
||||
&content_type,
|
||||
bool_param(params, "download", false),
|
||||
Some(&filename),
|
||||
);
|
||||
Some(passthrough_head_response(
|
||||
&content_type,
|
||||
total_len,
|
||||
None,
|
||||
Some(disposition),
|
||||
))
|
||||
}
|
||||
|
||||
async fn serve_external(
|
||||
app: &Arc<AppState>,
|
||||
method: Method,
|
||||
@@ -1028,6 +1191,18 @@ async fn serve_external(
|
||||
.filter(|rv| !rv.is_empty() && rv.bytes().all(|b| b.is_ascii_graphic()));
|
||||
let forward_range = if wants_transform { None } else { client_range };
|
||||
let allow_stream = !wants_transform;
|
||||
if wants_transform
|
||||
&& let Some(cached) = external_cached_transform(app, &url, &method, params, headers)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
if method == Method::HEAD
|
||||
&& !wants_transform
|
||||
&& client_range.is_none()
|
||||
&& let Some(response) = external_head_passthrough(app, &url, params).await
|
||||
{
|
||||
return response;
|
||||
}
|
||||
let mut fetched = match fetch_external_with_range(app, &url, forward_range, allow_stream).await
|
||||
{
|
||||
Ok(fetched) if fetched.status.is_success() => fetched,
|
||||
@@ -1117,43 +1292,29 @@ async fn serve_external(
|
||||
url: fetched_url,
|
||||
body,
|
||||
content_type,
|
||||
content_length,
|
||||
..
|
||||
} = fetched;
|
||||
let data = match body {
|
||||
ExternalBody::Streaming {
|
||||
response,
|
||||
content_length,
|
||||
} if client_range.is_none() => {
|
||||
ExternalBody::Streaming { response, prefix } => {
|
||||
let content_type = external_sniffed_content_type(&prefix, &filename, content_type);
|
||||
let disposition =
|
||||
content_disposition_header(&content_type, requested_download, Some(&filename));
|
||||
return external_streaming_response(
|
||||
method,
|
||||
response,
|
||||
prefix,
|
||||
content_length,
|
||||
&content_type,
|
||||
Some(disposition),
|
||||
);
|
||||
}
|
||||
body => match body.into_buffered(&fetched_url).await {
|
||||
Ok(data) => data,
|
||||
Err(ExternalFetchError::PayloadTooLarge) => {
|
||||
return text_with_source(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"Payload Too Large",
|
||||
"external_payload_too_large",
|
||||
&fetched_url,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return text_with_source(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Bad Gateway",
|
||||
"external_fetch_failed",
|
||||
format!("url={fetched_url} err={err:?}"),
|
||||
);
|
||||
}
|
||||
},
|
||||
ExternalBody::Buffered(data) => data,
|
||||
};
|
||||
app.external_hints.insert(
|
||||
url,
|
||||
external_hint(&fetched_url, &filename, &content_type, &data),
|
||||
);
|
||||
serve_bytes_or_transform(
|
||||
app,
|
||||
ServeBytesRequest {
|
||||
@@ -1175,6 +1336,7 @@ struct FetchedExternal {
|
||||
status: StatusCode,
|
||||
body: ExternalBody,
|
||||
content_type: String,
|
||||
content_length: Option<u64>,
|
||||
content_range: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1182,7 +1344,7 @@ enum ExternalBody {
|
||||
Buffered(Bytes),
|
||||
Streaming {
|
||||
response: reqwest::Response,
|
||||
content_length: u64,
|
||||
prefix: Bytes,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1190,30 +1352,59 @@ impl ExternalBody {
|
||||
async fn into_buffered(self, url: &str) -> Result<Bytes, ExternalFetchError> {
|
||||
match self {
|
||||
Self::Buffered(data) => Ok(data),
|
||||
Self::Streaming { response, .. } => buffer_external_response(response, url).await,
|
||||
Self::Streaming { response, prefix } => {
|
||||
buffer_external_response(response, prefix, url).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn external_stream_length(
|
||||
allow_stream: bool,
|
||||
content_length: Option<u64>,
|
||||
content_type: &str,
|
||||
) -> Option<u64> {
|
||||
if !allow_stream {
|
||||
return None;
|
||||
fn external_should_stream(allow_stream: bool, content_type: &str) -> bool {
|
||||
allow_stream && !is_svg_content_type(content_type)
|
||||
}
|
||||
|
||||
fn external_source_is_svg(prefix: &[u8], filename: &str, content_type: &str) -> bool {
|
||||
image_extension_from_filename(filename) == Some(AssetExtension::Svg)
|
||||
|| is_svg_content_type(&external_sniffed_content_type(
|
||||
prefix,
|
||||
filename,
|
||||
content_type.to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
struct ExternalStreamBody {
|
||||
prefix: Option<Bytes>,
|
||||
upstream: Limited<reqwest::Body>,
|
||||
}
|
||||
|
||||
impl http_body::Body for ExternalStreamBody {
|
||||
type Data = Bytes;
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn poll_frame(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
||||
let this = self.get_mut();
|
||||
if let Some(prefix) = this.prefix.take()
|
||||
&& !prefix.is_empty()
|
||||
{
|
||||
return Poll::Ready(Some(Ok(Frame::data(prefix))));
|
||||
}
|
||||
Pin::new(&mut this.upstream).poll_frame(cx)
|
||||
}
|
||||
let len = content_length?;
|
||||
if len > constants::MAX_MEDIA_PROXY_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
fn external_stream_body(method: &Method, response: reqwest::Response, prefix: Bytes) -> Body {
|
||||
if *method == Method::HEAD {
|
||||
return Body::empty();
|
||||
}
|
||||
if !content_type_is_trustworthy(content_type) {
|
||||
return None;
|
||||
}
|
||||
if is_svg_content_type(content_type) {
|
||||
return None;
|
||||
}
|
||||
Some(len)
|
||||
let remaining = constants::MAX_MEDIA_PROXY_BYTES.saturating_sub(prefix.len());
|
||||
let (_, upstream) = http::Response::from(response).into_parts();
|
||||
Body::new(ExternalStreamBody {
|
||||
prefix: Some(prefix),
|
||||
upstream: Limited::new(upstream, remaining),
|
||||
})
|
||||
}
|
||||
|
||||
fn external_partial_response(
|
||||
@@ -1224,12 +1415,13 @@ fn external_partial_response(
|
||||
let FetchedExternal {
|
||||
body,
|
||||
content_type,
|
||||
content_length,
|
||||
content_range,
|
||||
..
|
||||
} = fetched;
|
||||
let (body, body_len) = match body {
|
||||
ExternalBody::Buffered(data) => {
|
||||
let body_len = data.len() as u64;
|
||||
let body_len = Some(data.len() as u64);
|
||||
let body = if method == Method::HEAD {
|
||||
Body::empty()
|
||||
} else {
|
||||
@@ -1237,29 +1429,26 @@ fn external_partial_response(
|
||||
};
|
||||
(body, body_len)
|
||||
}
|
||||
ExternalBody::Streaming {
|
||||
response,
|
||||
ExternalBody::Streaming { response, prefix } => (
|
||||
external_stream_body(&method, response, prefix),
|
||||
content_length,
|
||||
} => {
|
||||
let body = if method == Method::HEAD {
|
||||
Body::empty()
|
||||
} else {
|
||||
Body::from_stream(response.bytes_stream())
|
||||
};
|
||||
(body, content_length)
|
||||
}
|
||||
),
|
||||
};
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
|
||||
http_headers::add_media_headers(
|
||||
response.headers_mut(),
|
||||
usize::try_from(body_len).unwrap_or(constants::MAX_MEDIA_PROXY_BYTES),
|
||||
body_len
|
||||
.and_then(|len| usize::try_from(len).ok())
|
||||
.unwrap_or(constants::MAX_MEDIA_PROXY_BYTES),
|
||||
&content_type,
|
||||
None,
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_LENGTH, HeaderValue::from(body_len));
|
||||
if let Some(body_len) = body_len {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_LENGTH, HeaderValue::from(body_len));
|
||||
}
|
||||
if let Some(cr) = content_range.as_deref()
|
||||
&& let Ok(value) = HeaderValue::from_str(cr)
|
||||
{
|
||||
@@ -1278,26 +1467,27 @@ fn external_partial_response(
|
||||
fn external_streaming_response(
|
||||
method: Method,
|
||||
response: reqwest::Response,
|
||||
content_length: u64,
|
||||
prefix: Bytes,
|
||||
content_length: Option<u64>,
|
||||
content_type: &str,
|
||||
disposition: Option<String>,
|
||||
) -> Response {
|
||||
let body = if method == Method::HEAD {
|
||||
Body::empty()
|
||||
} else {
|
||||
Body::from_stream(response.bytes_stream())
|
||||
};
|
||||
let body = external_stream_body(&method, response, prefix);
|
||||
let mut http_response = Response::new(body);
|
||||
*http_response.status_mut() = StatusCode::OK;
|
||||
http_headers::add_media_headers(
|
||||
http_response.headers_mut(),
|
||||
usize::try_from(content_length).unwrap_or(constants::MAX_MEDIA_PROXY_BYTES),
|
||||
content_length
|
||||
.and_then(|len| usize::try_from(len).ok())
|
||||
.unwrap_or(constants::MAX_MEDIA_PROXY_BYTES),
|
||||
content_type,
|
||||
None,
|
||||
);
|
||||
http_response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_LENGTH, HeaderValue::from(content_length));
|
||||
if let Some(content_length) = content_length {
|
||||
http_response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_LENGTH, HeaderValue::from(content_length));
|
||||
}
|
||||
if let Some(disposition) = disposition
|
||||
&& let Ok(value) = HeaderValue::from_str(&disposition)
|
||||
{
|
||||
@@ -1320,14 +1510,31 @@ async fn fetch_external(app: &AppState, url: &str) -> Result<FetchedExternal, Ex
|
||||
fetch_external_with_range(app, url, None, false).await
|
||||
}
|
||||
|
||||
async fn fetch_external_head(
|
||||
app: &AppState,
|
||||
url: &str,
|
||||
) -> Result<FetchedExternal, ExternalFetchError> {
|
||||
fetch_external_timed(app, url, Method::HEAD, None, false).await
|
||||
}
|
||||
|
||||
async fn fetch_external_with_range(
|
||||
app: &AppState,
|
||||
url: &str,
|
||||
range: Option<&str>,
|
||||
allow_stream: bool,
|
||||
) -> Result<FetchedExternal, ExternalFetchError> {
|
||||
fetch_external_timed(app, url, Method::GET, range, allow_stream).await
|
||||
}
|
||||
|
||||
async fn fetch_external_timed(
|
||||
app: &AppState,
|
||||
url: &str,
|
||||
method: Method,
|
||||
range: Option<&str>,
|
||||
allow_stream: bool,
|
||||
) -> Result<FetchedExternal, ExternalFetchError> {
|
||||
let start_ms = metrics::now_ms();
|
||||
let result = fetch_external_inner(app, url, range, allow_stream).await;
|
||||
let result = fetch_external_inner(app, url, method, range, allow_stream).await;
|
||||
request_log::record_stage(Stage::Fetch, (metrics::now_ms() - start_ms).max(0) as u64);
|
||||
result
|
||||
}
|
||||
@@ -1335,6 +1542,7 @@ async fn fetch_external_with_range(
|
||||
async fn fetch_external_inner(
|
||||
app: &AppState,
|
||||
url: &str,
|
||||
method: Method,
|
||||
range: Option<&str>,
|
||||
allow_stream: bool,
|
||||
) -> Result<FetchedExternal, ExternalFetchError> {
|
||||
@@ -1353,11 +1561,11 @@ async fn fetch_external_inner(
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return Err(ExternalFetchError::BlockedUrl);
|
||||
}
|
||||
let mut request = app.client.get(¤t_url);
|
||||
let mut request = app.client.request(method.clone(), ¤t_url);
|
||||
if let Some(rv) = range {
|
||||
request = request.header(header::RANGE, format!("bytes={rv}"));
|
||||
}
|
||||
let response = request.send().await.map_err(|err| {
|
||||
let mut response = request.send().await.map_err(|err| {
|
||||
warn!(url = %current_url, %err, "external send failed");
|
||||
ExternalFetchError::FetchFailed
|
||||
})?;
|
||||
@@ -1389,48 +1597,71 @@ async fn fetch_external_inner(
|
||||
.get(header::CONTENT_RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned);
|
||||
if let Some(len) = response.content_length()
|
||||
let content_length = response.content_length();
|
||||
if let Some(len) = content_length
|
||||
&& len > constants::MAX_MEDIA_PROXY_BYTES as u64
|
||||
{
|
||||
warn!(url = %current_url, len, "external payload too large");
|
||||
return Err(ExternalFetchError::PayloadTooLarge);
|
||||
}
|
||||
if status.is_success()
|
||||
&& let Some(content_length) =
|
||||
external_stream_length(allow_stream, response.content_length(), &content_type)
|
||||
{
|
||||
return Ok(FetchedExternal {
|
||||
url: current_url,
|
||||
status,
|
||||
body: ExternalBody::Streaming {
|
||||
response,
|
||||
let mut prefix = Bytes::new();
|
||||
if status.is_success() && external_should_stream(allow_stream, &content_type) {
|
||||
prefix = external_body_prefix(&mut response, ¤t_url).await?;
|
||||
if !external_source_is_svg(&prefix, &url_filename(¤t_url), &content_type) {
|
||||
return Ok(FetchedExternal {
|
||||
url: current_url,
|
||||
status,
|
||||
body: ExternalBody::Streaming { response, prefix },
|
||||
content_type,
|
||||
content_length,
|
||||
},
|
||||
content_type,
|
||||
content_range,
|
||||
});
|
||||
content_range,
|
||||
});
|
||||
}
|
||||
}
|
||||
let data = buffer_external_response(response, ¤t_url).await?;
|
||||
let data = buffer_external_response(response, prefix, ¤t_url).await?;
|
||||
return Ok(FetchedExternal {
|
||||
url: current_url,
|
||||
status,
|
||||
body: ExternalBody::Buffered(data),
|
||||
content_type,
|
||||
content_length,
|
||||
content_range,
|
||||
});
|
||||
}
|
||||
Err(ExternalFetchError::TooManyRedirects)
|
||||
}
|
||||
|
||||
async fn external_body_prefix(
|
||||
response: &mut reqwest::Response,
|
||||
url: &str,
|
||||
) -> Result<Bytes, ExternalFetchError> {
|
||||
let mut prefix: Vec<u8> = Vec::new();
|
||||
while prefix.len() < EXTERNAL_SNIFF_PREFIX_BYTES {
|
||||
let chunk = response.chunk().await.map_err(|err| {
|
||||
warn!(url = %url, %err, "external body read failed");
|
||||
ExternalFetchError::FetchFailed
|
||||
})?;
|
||||
let Some(chunk) = chunk else {
|
||||
break;
|
||||
};
|
||||
prefix.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(Bytes::from(prefix))
|
||||
}
|
||||
|
||||
async fn buffer_external_response(
|
||||
mut response: reqwest::Response,
|
||||
prefix: Bytes,
|
||||
url: &str,
|
||||
) -> Result<Bytes, ExternalFetchError> {
|
||||
let initial_capacity = response
|
||||
.content_length()
|
||||
.map(|len| len.min(constants::MAX_MEDIA_PROXY_BYTES as u64) as usize)
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(0)
|
||||
.saturating_add(prefix.len())
|
||||
.min(constants::MAX_MEDIA_PROXY_BYTES);
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(initial_capacity);
|
||||
buf.extend_from_slice(&prefix);
|
||||
while let Some(chunk) = response.chunk().await.map_err(|err| {
|
||||
warn!(url = %url, %err, "external body read failed");
|
||||
ExternalFetchError::FetchFailed
|
||||
@@ -3502,6 +3733,7 @@ mod tests {
|
||||
cfg.transform_cache_max_entry_bytes,
|
||||
cfg.transform_cache_ttl_ms,
|
||||
)),
|
||||
external_hints: new_external_hint_cache(&cfg),
|
||||
coalescer: Arc::new(ByteCoalescer::new()),
|
||||
native_transform_admissions: TimedSemaphore::new(transform_admission_capacity(&cfg)),
|
||||
native_transforms: TimedSemaphore::new(cfg.max_native_transforms),
|
||||
@@ -4108,37 +4340,52 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_stream_length_requires_safe_passthrough() {
|
||||
assert_eq!(
|
||||
Some(1024),
|
||||
external_stream_length(true, Some(1024), "video/mp4")
|
||||
);
|
||||
assert_eq!(None, external_stream_length(false, Some(1024), "video/mp4"));
|
||||
assert_eq!(None, external_stream_length(true, None, "video/mp4"));
|
||||
assert_eq!(
|
||||
None,
|
||||
external_stream_length(true, Some(1024), "application/octet-stream")
|
||||
);
|
||||
assert_eq!(
|
||||
None,
|
||||
external_stream_length(true, Some(1024), "image/svg+xml")
|
||||
);
|
||||
assert_eq!(
|
||||
None,
|
||||
external_stream_length(
|
||||
true,
|
||||
Some(constants::MAX_MEDIA_PROXY_BYTES as u64 + 1),
|
||||
"video/mp4"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Some(constants::MAX_MEDIA_PROXY_BYTES as u64),
|
||||
external_stream_length(
|
||||
true,
|
||||
Some(constants::MAX_MEDIA_PROXY_BYTES as u64),
|
||||
"video/mp4"
|
||||
)
|
||||
fn external_should_stream_covers_unknown_length_and_unlabelled_bodies() {
|
||||
assert!(external_should_stream(true, "video/mp4"));
|
||||
assert!(external_should_stream(true, "application/octet-stream"));
|
||||
assert!(external_should_stream(true, ""));
|
||||
assert!(!external_should_stream(false, "video/mp4"));
|
||||
assert!(!external_should_stream(true, "image/svg+xml"));
|
||||
assert!(!external_should_stream(
|
||||
true,
|
||||
"image/svg+xml; charset=utf-8"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_source_is_svg_matches_the_transform_paths_svg_test() {
|
||||
let padded_svg = {
|
||||
let mut data = b"<!--".to_vec();
|
||||
data.resize(6000, b' ');
|
||||
data.extend_from_slice(b"--><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
|
||||
data
|
||||
};
|
||||
assert!(
|
||||
!mime::sniff(&padded_svg[..8192.min(padded_svg.len())])
|
||||
.mime
|
||||
.eq("image/svg+xml")
|
||||
);
|
||||
assert!(external_source_is_svg(
|
||||
&padded_svg,
|
||||
"logo.svg",
|
||||
"application/octet-stream"
|
||||
));
|
||||
assert!(external_source_is_svg(
|
||||
b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
|
||||
"logo",
|
||||
"application/octet-stream"
|
||||
));
|
||||
assert!(external_source_is_svg(
|
||||
b"\x89PNG\r\n\x1a\n",
|
||||
"logo.svg",
|
||||
"image/png"
|
||||
));
|
||||
assert!(!external_source_is_svg(
|
||||
b"\x89PNG\r\n\x1a\n",
|
||||
"logo.png",
|
||||
"image/png"
|
||||
));
|
||||
assert!(!external_source_is_svg(b"", "clip.mp4", ""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4152,7 +4399,8 @@ mod tests {
|
||||
let response = external_streaming_response(
|
||||
Method::GET,
|
||||
upstream,
|
||||
14,
|
||||
Bytes::new(),
|
||||
Some(14),
|
||||
"video/mp4",
|
||||
Some("inline; filename=\"clip.mp4\"".to_owned()),
|
||||
);
|
||||
@@ -4180,6 +4428,104 @@ mod tests {
|
||||
assert_eq!(b"streamed bytes", body.as_ref());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_streaming_response_emits_the_sniff_prefix_then_the_rest() {
|
||||
let upstream = reqwest::Response::from(
|
||||
http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body("rest of the body")
|
||||
.unwrap(),
|
||||
);
|
||||
let response = external_streaming_response(
|
||||
Method::GET,
|
||||
upstream,
|
||||
Bytes::from_static(b"prefix "),
|
||||
None,
|
||||
"application/octet-stream",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(StatusCode::OK, response.status());
|
||||
assert!(
|
||||
response.headers().get(header::CONTENT_LENGTH).is_none(),
|
||||
"an upstream without a Content-Length must stream without inventing one"
|
||||
);
|
||||
let body = to_bytes(response.into_body(), 64).await.unwrap();
|
||||
assert_eq!(b"prefix rest of the body", body.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_sniffed_content_type_recovers_mislabelled_bodies() {
|
||||
assert_eq!(
|
||||
"image/svg+xml",
|
||||
external_sniffed_content_type(
|
||||
b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
|
||||
"logo",
|
||||
"application/octet-stream".to_owned()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
"video/mp4",
|
||||
external_sniffed_content_type(b"", "clip.mp4", "application/octet-stream".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
"video/webm",
|
||||
external_sniffed_content_type(b"", "clip.mp4", "video/webm".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_transform_probe_hits_the_entry_the_transform_path_wrote() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg =
|
||||
upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901");
|
||||
let app = test_app_state(cfg);
|
||||
let url = "https://cdn.example.test/photo.jpg";
|
||||
let params = HashMap::from([("width".to_owned(), "32".to_owned())]);
|
||||
let headers = HeaderMap::new();
|
||||
let data = Bytes::from(STANDARD.decode(TRANSFORM_FIXTURE_JPEG_B64).unwrap());
|
||||
let filename = url_filename(url);
|
||||
|
||||
assert!(
|
||||
external_cached_transform(&app, url, &Method::GET, ¶ms, &headers).is_none(),
|
||||
"an unseen url must not resolve to a cached transform"
|
||||
);
|
||||
|
||||
let response = serve_bytes_or_transform(
|
||||
&app,
|
||||
ServeBytesRequest {
|
||||
method: Method::GET,
|
||||
data: data.clone(),
|
||||
content_type: "image/jpeg".to_owned(),
|
||||
cache_identity: url,
|
||||
filename: &filename,
|
||||
route: TransformRoute::External,
|
||||
params: ¶ms,
|
||||
headers: &headers,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(StatusCode::OK, response.status());
|
||||
let transformed = to_bytes(response.into_body(), 1 << 20).await.unwrap();
|
||||
|
||||
app.external_hints.insert(
|
||||
url.to_owned(),
|
||||
external_hint(url, &filename, "image/jpeg", &data),
|
||||
);
|
||||
let probed = external_cached_transform(&app, url, &Method::GET, ¶ms, &headers)
|
||||
.expect("the probe must reach the entry the transform path wrote");
|
||||
|
||||
assert_eq!(StatusCode::OK, probed.status());
|
||||
assert_eq!(
|
||||
"image/jpeg",
|
||||
probed.headers().get(header::CONTENT_TYPE).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
transformed,
|
||||
to_bytes(probed.into_body(), 1 << 20).await.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_partial_response_streams_partial_body() {
|
||||
let upstream = reqwest::Response::from(
|
||||
@@ -4193,9 +4539,10 @@ mod tests {
|
||||
status: StatusCode::PARTIAL_CONTENT,
|
||||
body: ExternalBody::Streaming {
|
||||
response: upstream,
|
||||
content_length: 4,
|
||||
prefix: Bytes::new(),
|
||||
},
|
||||
content_type: "video/webm".to_owned(),
|
||||
content_length: Some(4),
|
||||
content_range: Some("bytes 0-3/10".to_owned()),
|
||||
};
|
||||
let response = external_partial_response(Method::GET, fetched, None);
|
||||
@@ -4244,8 +4591,14 @@ mod tests {
|
||||
.body("streamed bytes")
|
||||
.unwrap(),
|
||||
);
|
||||
let response =
|
||||
external_streaming_response(Method::GET, upstream, 14, content_type, None);
|
||||
let response = external_streaming_response(
|
||||
Method::GET,
|
||||
upstream,
|
||||
Bytes::new(),
|
||||
Some(14),
|
||||
content_type,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
expected,
|
||||
@@ -4262,6 +4615,7 @@ mod tests {
|
||||
status: StatusCode::PARTIAL_CONTENT,
|
||||
body: ExternalBody::Buffered(Bytes::from_static(b"abcd")),
|
||||
content_type: "video/webm".to_owned(),
|
||||
content_length: Some(4),
|
||||
content_range: Some("bytes 0-3/10".to_owned()),
|
||||
};
|
||||
let response = external_partial_response(Method::GET, fetched, None);
|
||||
@@ -4306,6 +4660,7 @@ mod tests {
|
||||
status: StatusCode::PARTIAL_CONTENT,
|
||||
body: ExternalBody::Buffered(Bytes::from_static(b"abcd")),
|
||||
content_type: "video/webm".to_owned(),
|
||||
content_length: Some(4),
|
||||
content_range: Some("bytes 0-3/10".to_owned()),
|
||||
};
|
||||
let response = external_partial_response(
|
||||
|
||||
Reference in New Issue
Block a user