Compare commits

...
12 changed files with 188 additions and 24 deletions

No files matched your search

@@ -9,6 +9,13 @@ import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandi
import {createRootRoute, createRoute} from '@app/features/platform/components/router/RouterBuilder';
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
const enterDefaultLanding = afterSessionInitialization(() => {
if (readAuthenticatedRuntimeContext() === null) {
return new Redirect(Routes.LOGIN);
}
return new Redirect(getDefaultLandingPath());
});
export const rootRoute = createRootRoute({
layout: ({children}) => (
<RootComponent data-flx="app.router.root-routes.layout.root-component">{children}</RootComponent>
@@ -23,16 +30,11 @@ export const homeRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'home',
path: '/',
onEnter: afterSessionInitialization(() => {
if (readAuthenticatedRuntimeContext() === null) {
return new Redirect(Routes.LOGIN);
}
return new Redirect(getDefaultLandingPath());
}),
onEnter: enterDefaultLanding,
});
export const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
path: Routes.APP,
onEnter: () => new Redirect(getDefaultLandingPath()),
onEnter: enterDefaultLanding,
});
+1 -1
View File
@@ -16,7 +16,7 @@ pub const MAX_ANIMATED_FRAMES_DEFAULT: u32 = 20_000;
pub const MAX_ANIMATED_TOTAL_PIXELS_DEFAULT: usize = 4 * MAX_MEDIA_IMAGE_PIXELS_DEFAULT;
pub const MAX_JXL_DECODE_PIXELS: usize = 4096 * 4096;
pub const MAX_STATIC_OUTPUT_PIXELS: usize = 6144 * 4096;
pub const MAX_GIF_RESIZE_OUTPUT_PIXELS: usize = 16 << 20;
pub const MAX_ANIMATED_OUTPUT_PIXELS: usize = 16 << 20;
pub const SVG_RASTER_MAX_DIMENSION: u32 = 4096;
pub const LARGE_SOURCE_LOG_PIXELS: usize = 4096 * 4096;
const _: () = assert!(MAX_ANIMATED_FRAMES_DEFAULT >= 20_000);
+49 -1
View File
@@ -14,10 +14,15 @@ use std::time::Duration;
pub type HttpClient = ClientWithMiddleware;
const EXTERNAL_CONNECT_TIMEOUT_MS: u64 = 3_000;
const EXTERNAL_READ_TIMEOUT_MS: u64 = 10_000;
const EXTERNAL_RETRY_WINDOW_MS: u64 = 15_000;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HTTPClientOptions {
connect_timeout_ms: NonZeroU64,
request_timeout_ms: NonZeroU64,
read_timeout_ms: Option<NonZeroU64>,
retries: HTTPRetryPolicy,
address_policy: HTTPAddressPolicy,
}
@@ -27,6 +32,7 @@ struct HTTPRetryPolicy {
max_retries: NonZeroU32,
min_delay_ms: NonZeroU64,
max_delay_ms: NonZeroU64,
window_ms: Option<NonZeroU64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -44,6 +50,18 @@ impl HTTPClientOptions {
}
}
pub fn external(request_timeout_ms: NonZeroU64) -> Self {
let mut options = Self::new(
NonZeroU64::new(EXTERNAL_CONNECT_TIMEOUT_MS)
.expect("external connection timeout must be nonzero"),
request_timeout_ms,
)
.restrict_to_public();
options.read_timeout_ms = NonZeroU64::new(EXTERNAL_READ_TIMEOUT_MS);
options.retries.window_ms = NonZeroU64::new(EXTERNAL_RETRY_WINDOW_MS);
options
}
pub fn restrict_to_public(mut self) -> Self {
self.address_policy = HTTPAddressPolicy::PublicOnly;
self
@@ -57,6 +75,7 @@ impl Default for HTTPClientOptions {
.expect("default connection timeout must be nonzero"),
request_timeout_ms: NonZeroU64::new(30_000)
.expect("default request timeout must be nonzero"),
read_timeout_ms: None,
retries: HTTPRetryPolicy {
max_retries: NonZeroU32::new(2)
.expect("default maximum retry count must be nonzero"),
@@ -64,6 +83,7 @@ impl Default for HTTPClientOptions {
.expect("default minimum retry delay must be nonzero"),
max_delay_ms: NonZeroU64::new(500)
.expect("default maximum retry delay must be nonzero"),
window_ms: None,
},
address_policy: HTTPAddressPolicy::Any,
}
@@ -80,6 +100,9 @@ pub fn build_raw(options: HTTPClientOptions) -> Result<reqwest::Client, reqwest:
.timeout(Duration::from_millis(options.request_timeout_ms.get()))
.redirect(reqwest::redirect::Policy::none())
.user_agent(crate::constants::OUTBOUND_USER_AGENT);
if let Some(read_timeout_ms) = options.read_timeout_ms {
builder = builder.read_timeout(Duration::from_millis(read_timeout_ms.get()));
}
if options.address_policy == HTTPAddressPolicy::PublicOnly {
builder = builder.dns_resolver(Arc::new(PinnedDnsResolver));
}
@@ -96,6 +119,7 @@ pub fn build(
max_retries,
min_delay_ms,
max_delay_ms,
window_ms,
} = options.retries;
let retry_policy = ExponentialBackoff::builder()
.retry_bounds(
@@ -105,7 +129,11 @@ pub fn build(
.build_with_max_retries(max_retries.get());
Ok(builder
.with(RetryTransientMiddleware::new_with_policy_and_strategy(
ObservableRetryPolicy::new(retry_policy, Arc::clone(&metrics)),
ObservableRetryPolicy::new(
retry_policy,
window_ms.map(|window| Duration::from_millis(window.get())),
Arc::clone(&metrics),
),
MediaProxyRetryStrategy::new(metrics),
))
.build())
@@ -157,4 +185,24 @@ mod tests {
.expect_err("the pinned resolver rejects a loopback host");
assert!(crate::public_net_policy::is_pinned_dns_failure(&error));
}
#[test]
fn the_external_client_fails_fast_on_slow_hosts() {
let options = HTTPClientOptions::external(millis(30_000));
assert_eq!(
EXTERNAL_CONNECT_TIMEOUT_MS,
options.connect_timeout_ms.get()
);
assert_eq!(30_000, options.request_timeout_ms.get());
assert_eq!(
Some(EXTERNAL_READ_TIMEOUT_MS),
options.read_timeout_ms.map(NonZeroU64::get)
);
assert_eq!(
Some(EXTERNAL_RETRY_WINDOW_MS),
options.retries.window_ms.map(NonZeroU64::get)
);
assert_eq!(HTTPAddressPolicy::PublicOnly, options.address_policy);
build_raw(options).expect("the external client configuration is valid");
}
}
+57 -5
View File
@@ -8,22 +8,40 @@ use reqwest_retry::{
};
use std::io::ErrorKind;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
pub(super) struct ObservableRetryPolicy {
inner: ExponentialBackoff,
retry_window: Option<Duration>,
metrics: Arc<HTTPClientMetrics>,
}
impl ObservableRetryPolicy {
pub(super) fn new(inner: ExponentialBackoff, metrics: Arc<HTTPClientMetrics>) -> Self {
Self { inner, metrics }
pub(super) fn new(
inner: ExponentialBackoff,
retry_window: Option<Duration>,
metrics: Arc<HTTPClientMetrics>,
) -> Self {
Self {
inner,
retry_window,
metrics,
}
}
}
impl RetryPolicy for ObservableRetryPolicy {
fn should_retry(&self, request_start_time: SystemTime, n_past_retries: u32) -> RetryDecision {
let decision = self.inner.should_retry(request_start_time, n_past_retries);
let window_spent = self.retry_window.is_some_and(|window| {
request_start_time
.elapsed()
.is_ok_and(|elapsed| elapsed >= window)
});
let decision = if window_spent {
RetryDecision::DoNotRetry
} else {
self.inner.should_retry(request_start_time, n_past_retries)
};
match decision {
RetryDecision::Retry { .. } => self.metrics.record_retry(),
RetryDecision::DoNotRetry => self.metrics.record_retries_exhausted(),
@@ -263,7 +281,7 @@ mod tests {
let backoff = ExponentialBackoff::builder()
.retry_bounds(Duration::from_millis(1), Duration::from_millis(2))
.build_with_max_retries(1);
let policy = ObservableRetryPolicy::new(backoff, metrics.http_client());
let policy = ObservableRetryPolicy::new(backoff, None, metrics.http_client());
let started = SystemTime::now();
assert!(matches!(
policy.should_retry(started, 0),
@@ -329,4 +347,38 @@ mod tests {
Some(Retryable::Transient)
));
}
fn policy(window: Option<Duration>) -> ObservableRetryPolicy {
ObservableRetryPolicy::new(
ExponentialBackoff::builder().build_with_max_retries(2),
window,
Arc::new(HTTPClientMetrics::new()),
)
}
#[test]
fn a_request_past_its_retry_window_is_not_retried() {
let started = SystemTime::now() - Duration::from_secs(20);
assert!(matches!(
policy(Some(Duration::from_secs(15))).should_retry(started, 0),
RetryDecision::DoNotRetry
));
}
#[test]
fn a_request_inside_its_retry_window_is_retried() {
assert!(matches!(
policy(Some(Duration::from_secs(15))).should_retry(SystemTime::now(), 0),
RetryDecision::Retry { .. }
));
}
#[test]
fn a_policy_without_a_window_keeps_retrying_slow_requests() {
let started = SystemTime::now() - Duration::from_secs(60);
assert!(matches!(
policy(None).should_retry(started, 0),
RetryDecision::Retry { .. }
));
}
}
@@ -182,7 +182,7 @@ impl GifResizeDims {
(self.width as usize)
.saturating_mul(self.height as usize)
.saturating_mul(frames.max(1) as usize)
<= constants::MAX_GIF_RESIZE_OUTPUT_PIXELS
<= constants::MAX_ANIMATED_OUTPUT_PIXELS
}
}
@@ -13,8 +13,8 @@ use super::image_probe::{
};
use super::loaded_image::{
normalize_vips_image_to_uchar, page_height, resize_loaded_image, resize_loaded_image_by_scale,
validate_dimensions_u32, validate_jxl_decode_pixels, validate_static_output_pixels,
validate_vips_image,
static_output_pixels, validate_dimensions_u32, validate_jxl_decode_pixels,
validate_static_output_pixels, validate_vips_image,
};
use super::native_runtime::{clear_vips_error, ensure_vips_init, last_vips_error};
use super::transform_plan::{
@@ -128,6 +128,31 @@ fn ensure_decode_cost_admissible(
admitted
}
fn ensure_animated_output_admissible(
input: &[u8],
options: &ImageOptions,
media_limits: &MediaLimits,
) -> Result<(), MediaError> {
let dims = probe_image_dims(media_limits, input)?;
if dims.pages <= 1 {
return Ok(());
}
let output_pixels = static_output_pixels(options, dims).saturating_mul(dims.pages as usize);
if output_pixels <= constants::MAX_ANIMATED_OUTPUT_PIXELS {
return Ok(());
}
tracing::warn!(
input_bytes = input.len(),
width = dims.width,
height = dims.height,
pages = dims.pages,
out_width = options.width.unwrap_or(0),
out_height = options.height.unwrap_or(0),
"animated transform refused by the output work budget"
);
Err(MediaError::TooLargeToProcess)
}
pub fn transform_image(
input: &[u8],
options: &ImageOptions,
@@ -184,6 +209,9 @@ pub fn transform_image(
content_type: "image/gif",
});
}
if animated {
ensure_animated_output_admissible(input, options, media_limits)?;
}
if should_transform_animated_webp_direct(sniffed, options, format) {
validate_dimensions_u32(media_limits, sniffed.width, sniffed.height)?;
let resolved = resolve_auto_quality(
+1 -1
View File
@@ -42,7 +42,7 @@ impl AppState {
NonZeroU64::new(cfg.socket_io_timeout_ms).unwrap_or(NonZeroU64::MIN);
let store = Store::try_new(cfg.clone(), metrics.storage(), metrics.http_client())?;
let external_client = http_client::build(
HTTPClientOptions::new(socket_io_timeout, socket_io_timeout).restrict_to_public(),
HTTPClientOptions::external(socket_io_timeout),
metrics.http_client(),
)?;
let nsfw = NSFWClient::new(
@@ -574,8 +574,8 @@ async fn serve_image_transform(
requested_download: transform.requested_download,
});
}
let can_serve_original = transform.width.is_none()
&& transform.height.is_none()
let can_serve_original = ((transform.width.is_none() && transform.height.is_none())
|| options.is_animated())
&& is_browser_displayable_image(sniffed.mime);
let mut too_large_original = None;
let leader_original = &mut too_large_original;
@@ -325,3 +325,35 @@ fn a_cache_larger_than_the_pod_takes_at_most_half_of_it() {
fn the_budget_never_drops_below_its_floor() {
assert_eq!(128 << 20, native_memory_budget_bytes(64 << 20, 0));
}
#[tokio::test]
async fn an_animated_transform_over_the_work_budget_serves_the_original() {
let webp = crate::test_fixtures::ANIMATED_WEBP_1024_17_FRAMES;
let response = attachment_transform(
webp,
"anim.webp",
&[("format", "webp"), ("animated", "true"), ("width", "1000")],
)
.await;
assert_eq!(StatusCode::OK, response.status());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(webp, body.as_ref());
}
#[tokio::test]
async fn an_animated_transform_within_the_work_budget_is_transformed() {
let webp = crate::test_fixtures::ANIMATED_WEBP_1024_17_FRAMES;
let response = attachment_transform(
webp,
"anim.webp",
&[("format", "webp"), ("animated", "true"), ("width", "128")],
)
.await;
assert_eq!(StatusCode::OK, response.status());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_ne!(webp, body.as_ref());
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -32,6 +32,7 @@ pub fn apng_header(frames: u32) -> Vec<u8> {
pub const JXL_4100_LOSSY_ALPHA: &[u8] = include_bytes!("jxl_4100_lossy_alpha.jxl");
pub const JXL_64_LOSSY_ALPHA: &[u8] = include_bytes!("jxl_64_lossy_alpha.jxl");
pub const ANIMATED_WEBP_1024_17_FRAMES: &[u8] = include_bytes!("animated_webp_1024_17_frames.webp");
pub const JPEG_ROTATED_MISMATCHED_SCAN: &[u8] = include_bytes!("jpeg_rotated_mismatched_scan.jpg");
pub fn synthetic_png(width: u32, height: u32) -> Vec<u8> {
+5 -4
View File
@@ -10,10 +10,11 @@ pub use adversarial::{
};
pub use ffmpeg_cli::{ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4};
pub use images::{
JPEG_ROTATED_MISMATCHED_SCAN, JXL_64_LOSSY_ALPHA, JXL_4100_LOSSY_ALPHA, animated_gif_fixture,
animated_gif_frames, apng_header, first_webp_anim_frame_size, gif_frame_delays_cs,
gif_loop_count, minimal_gif, png_dimensions, synthetic_bmp, synthetic_png,
webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads, webp_with_metadata_chunk,
ANIMATED_WEBP_1024_17_FRAMES, JPEG_ROTATED_MISMATCHED_SCAN, JXL_64_LOSSY_ALPHA,
JXL_4100_LOSSY_ALPHA, animated_gif_fixture, animated_gif_frames, apng_header,
first_webp_anim_frame_size, gif_frame_delays_cs, gif_loop_count, minimal_gif, png_dimensions,
synthetic_bmp, synthetic_png, webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads,
webp_with_metadata_chunk,
};
pub use media::{
fixture_audio_mp3_with_png_cover_art, fixture_audio_mp4_with_attached_picture,