diff --git a/Cargo.lock b/Cargo.lock index b3a807d5f..a9687eea3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1461,12 +1461,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - [[package]] name = "deadpool" version = "0.12.3" @@ -2026,7 +2020,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "svg-hush", "tempfile", "thiserror", "tokio", @@ -5258,19 +5251,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "svg-hush" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929223e80cdcec0482207576ea09692dd71b2b559057fc172e292ecec9a97559" -dependencies = [ - "base64", - "data-url", - "quick-error 2.0.1", - "url", - "xml", -] - [[package]] name = "syn" version = "2.0.117" @@ -6564,12 +6544,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xml" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" - [[package]] name = "xmlparser" version = "0.13.6" diff --git a/fluxer_media_proxy/Cargo.toml b/fluxer_media_proxy/Cargo.toml index ec2f4f53c..3efb8f087 100644 --- a/fluxer_media_proxy/Cargo.toml +++ b/fluxer_media_proxy/Cargo.toml @@ -44,7 +44,6 @@ reqwest-retry = "0.9.1" serde = {version = "1.0.228", features = ["derive"]} serde_json = "1.0.150" sha2 = "0.11.0" -svg-hush = "0.9.6" tempfile = "3.27.0" thiserror = "2.0.18" tokio = {version = "1.52.3", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"]} diff --git a/fluxer_media_proxy/src/disposition.rs b/fluxer_media_proxy/src/disposition.rs index d8974b95b..f21d044a7 100644 --- a/fluxer_media_proxy/src/disposition.rs +++ b/fluxer_media_proxy/src/disposition.rs @@ -29,6 +29,9 @@ fn normalize_mime(content_type: &str) -> &str { pub fn is_inline_viewable(content_type: &str) -> bool { let mime = normalize_mime(content_type); + if mime.eq_ignore_ascii_case("image/svg+xml") { + return false; + } if mime.len() >= 6 && mime[..6].eq_ignore_ascii_case("image/") { return true; } @@ -111,7 +114,7 @@ mod tests { ); assert_eq!(Decision::Inline, decide("video/mp4", false)); assert_eq!(Decision::Inline, decide("application/pdf", false)); - assert_eq!(Decision::Inline, decide("image/svg+xml", false)); + assert_eq!(Decision::Attachment, decide("image/svg+xml", false)); assert_eq!( Decision::Attachment, decide("application/octet-stream", false) @@ -132,7 +135,7 @@ mod tests { #[test] fn case_insensitive_mime_matching() { assert_eq!(Decision::Inline, decide("IMAGE/PNG", false)); - assert_eq!(Decision::Inline, decide("Image/Svg+Xml", false)); + assert_eq!(Decision::Attachment, decide("Image/Svg+Xml", false)); } #[test] diff --git a/fluxer_media_proxy/src/lib.rs b/fluxer_media_proxy/src/lib.rs index e4e316e9e..4a604b96b 100644 --- a/fluxer_media_proxy/src/lib.rs +++ b/fluxer_media_proxy/src/lib.rs @@ -27,7 +27,6 @@ pub mod server; pub mod signing; pub mod spool; pub mod storage; -pub mod svg; pub mod thumbhash; pub mod timed_semaphore; pub mod upload_relay; diff --git a/fluxer_media_proxy/src/output_format.rs b/fluxer_media_proxy/src/output_format.rs index dceb48eef..f842d0cff 100644 --- a/fluxer_media_proxy/src/output_format.rs +++ b/fluxer_media_proxy/src/output_format.rs @@ -20,12 +20,16 @@ pub struct OutputSelection { pub fn is_output_format_supported(ext: AssetExtension) -> bool { !matches!( ext, - AssetExtension::Avif | AssetExtension::Heic | AssetExtension::Heif | AssetExtension::Jxl + AssetExtension::Avif + | AssetExtension::Heic + | AssetExtension::Heif + | AssetExtension::Jxl + | AssetExtension::Svg ) } pub fn can_encode_to(ext: AssetExtension) -> bool { - is_output_format_supported(ext) && ext != AssetExtension::Svg + is_output_format_supported(ext) } pub fn coerce_unsupported_format(ext: AssetExtension) -> AssetExtension { @@ -80,6 +84,18 @@ mod tests { assert_eq!("url-coerced", r.reason); } + #[test] + fn svg_url_extension_coerces_to_webp() { + let r = select_url_variant(Input { + kind: AssetKind::Avatar, + original: AssetExtension::Svg, + requested_size: Some(128), + manual_format_override: None, + }); + assert_eq!(AssetExtension::Webp, r.format); + assert_eq!("url-coerced", r.reason); + } + #[test] fn manual_query_format_wins_over_url_extension() { let r = select_url_variant(Input { diff --git a/fluxer_media_proxy/src/server.rs b/fluxer_media_proxy/src/server.rs index 7855f6b7c..807cefe2d 100644 --- a/fluxer_media_proxy/src/server.rs +++ b/fluxer_media_proxy/src/server.rs @@ -13,7 +13,6 @@ use crate::{ signing, spool::{SpoolError, spool_to_temp}, storage::{HeadResult, RelayBody, RelayPutOptions, StorageError, Store, StreamObject}, - svg, timed_semaphore::TimedSemaphore, upload_relay, }; @@ -262,10 +261,16 @@ async fn metadata_handler( "allow" => false, _ => return text(StatusCode::BAD_REQUEST, "Bad Request"), }; - let input = match load_metadata_input(&app, &req).await { + let mut input = match load_metadata_input(&app, &req).await { Ok(input) => input, Err(status) => return text(status, status.canonical_reason().unwrap_or("Bad Request")), }; + if req.with_base64.unwrap_or(false) && metadata_input_is_svg(&input) { + input = match rasterize_metadata_svg(&app, input).await { + Ok(input) => input, + Err(response) => return response, + }; + } let json = match media_process::metadata_json_with_options( &input.data, &input.filename, @@ -304,6 +309,55 @@ struct InputData { filename: String, } +fn metadata_input_is_svg(input: &InputData) -> bool { + mime::sniff(&input.data).mime == "image/svg+xml" + || image_extension_from_filename(&input.filename) == Some(AssetExtension::Svg) +} + +fn replace_image_extension(filename: &str, ext: AssetExtension) -> String { + let last_slash = filename.rfind('/').map(|idx| idx + 1).unwrap_or(0); + let last_dot = filename[last_slash..] + .rfind('.') + .map(|idx| last_slash + idx); + match last_dot { + Some(idx) => format!("{}.{}", &filename[..idx], ext.name()), + None => format!("{}.{}", filename, ext.name()), + } +} + +async fn rasterize_metadata_svg( + app: &Arc, + input: InputData, +) -> Result { + let options = media_process::ImageOptions { + format: AssetExtension::Webp, + quality: "lossless".to_owned(), + animated: false, + deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64), + max_encode_frames: Some(app.cfg.max_encode_frames), + max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms), + ..Default::default() + }; + match run_transform(app, input.data, options).await { + Ok(media) => Ok(InputData { + data: media.bytes.into(), + filename: replace_image_extension(&input.filename, AssetExtension::Webp), + }), + Err(err) if transform_error_is_timeout(&err) => Err(text_with_source( + StatusCode::GATEWAY_TIMEOUT, + "Gateway Timeout", + "metadata_svg_rasterize_timeout", + input.filename, + )), + Err(err) => Err(text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "metadata_svg_rasterize_failed", + format!("filename={} err={err:?}", input.filename), + )), + } +} + async fn load_metadata_input( app: &AppState, req: &MetadataRequest, @@ -939,7 +993,10 @@ async fn serve_external( Ok(url) => url, Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), }; - let wants_transform = params.contains_key("width") + let url_ext_is_svg = + image_extension_from_filename(&url_filename(&url)) == Some(AssetExtension::Svg); + let wants_transform = url_ext_is_svg + || params.contains_key("width") || params.contains_key("height") || params.contains_key("format") || params.contains_key("quality") @@ -951,7 +1008,8 @@ 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; - let fetched = match fetch_external_with_range(app, &url, forward_range, allow_stream).await { + let mut fetched = match fetch_external_with_range(app, &url, forward_range, allow_stream).await + { Ok(fetched) if fetched.status.is_success() => fetched, Ok(fetched) => { return text_with_source( @@ -987,6 +1045,47 @@ async fn serve_external( ); } }; + if forward_range.is_some() + && fetched.status == StatusCode::PARTIAL_CONTENT + && is_svg_content_type(&fetched.content_type) + { + fetched = match fetch_external_with_range(app, &url, None, false).await { + Ok(fetched) if fetched.status.is_success() => fetched, + Ok(fetched) => { + return text_with_source( + map_upstream_status(fetched.status), + "Upstream fetch failed", + "external_upstream_status", + format!("url={url} upstream_status={}", fetched.status.as_u16()), + ); + } + Err(ExternalFetchError::BlockedUrl) => { + return text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "external_blocked_url", + &url, + ); + } + Err(ExternalFetchError::PayloadTooLarge) => { + return text_with_source( + StatusCode::PAYLOAD_TOO_LARGE, + "Payload Too Large", + "external_payload_too_large", + &url, + ); + } + Err(err @ ExternalFetchError::TooManyRedirects) + | Err(err @ ExternalFetchError::FetchFailed) => { + return text_with_source( + StatusCode::BAD_GATEWAY, + "Bad Gateway", + "external_fetch_failed", + format!("url={url} err={err:?}"), + ); + } + }; + } let filename = url_filename(&fetched.url); let requested_download = bool_param(params, "download", false); if forward_range.is_some() && fetched.status == StatusCode::PARTIAL_CONTENT { @@ -1091,7 +1190,7 @@ fn external_stream_length( if !content_type_is_trustworthy(content_type) { return None; } - if content_type.eq_ignore_ascii_case("image/svg+xml") { + if is_svg_content_type(content_type) { return None; } Some(len) @@ -1403,29 +1502,6 @@ async fn serve_asset_image( .or(sniffed_source_ext) .unwrap_or(asset.original_ext) }; - if source_format == AssetExtension::Svg { - let sanitized = match svg::sanitize(&object.data) { - Ok(bytes) => bytes, - Err(_) => { - return text_with_reason( - StatusCode::BAD_REQUEST, - "Bad Request", - "svg_sanitize_failed", - ); - } - }; - return media_response( - method, - Bytes::from(sanitized), - "image/svg+xml", - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - Some(content_disposition_header( - "image/svg+xml", - requested_download, - Some(&asset_filename), - )), - ); - } let serve_content_type = if object.content_type.is_empty() || object .content_type @@ -1535,7 +1611,9 @@ async fn serve_asset_image( let src_is_displayable = src_ct.starts_with("image/") && src_ct != "image/avif" && src_ct != "image/heic" - && src_ct != "image/heif"; + && src_ct != "image/heif" + && source_format != AssetExtension::Svg + && !is_svg_content_type(src_ct); if !src_is_displayable { return text_with_source( StatusCode::INTERNAL_SERVER_ERROR, @@ -1681,28 +1759,23 @@ async fn serve_stored_passthrough_stream( return storage_error_response(key, StorageError::StreamTooLong); } let content_type = passthrough_content_type(&head, key); - if content_type.eq_ignore_ascii_case("image/svg+xml") { + if is_svg_content_type(&content_type) + || image_extension_from_filename(key) == Some(AssetExtension::Svg) + { let object = match app.store.read_object(bucket, key).await { Ok(object) => object, Err(err) => return storage_error_response(key, err), }; - let sanitized = match svg::sanitize(&object.data) { - Ok(bytes) => bytes, - Err(_) => { - return text_with_reason( - StatusCode::BAD_REQUEST, - "Bad Request", - "svg_sanitize_failed", - ); - } - }; - return media_response( + let cache_identity = format!("{bucket}/{key}"); + return serve_stored_svg_rasterized( + app, method, - Bytes::from(sanitized), - &content_type, - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - passthrough_disposition_header(&disposition, &content_type), - ); + object.data, + &cache_identity, + headers, + &disposition, + ) + .await; } let total_len = match usize::try_from(head.content_length) { Ok(value) => value, @@ -1783,6 +1856,91 @@ fn passthrough_disposition_header( } } +async fn serve_stored_svg_rasterized( + app: &Arc, + method: Method, + data: Bytes, + cache_identity: &str, + headers: &HeaderMap, + disposition: &PassthroughDisposition<'_>, +) -> Response { + let format = AssetExtension::Webp; + let quality = "lossless".to_owned(); + let options = media_process::ImageOptions { + format, + quality: quality.clone(), + animated: false, + deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64), + max_encode_frames: Some(app.cfg.max_encode_frames), + max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms), + ..Default::default() + }; + let cache_key = transform_cache_key(TransformCacheKeyInput { + route: TransformRoute::Stored, + cache_identity, + width: None, + height: None, + format, + quality: &quality, + animated: false, + effort: None, + }); + if let Some(cached) = app.transform_cache.get(&cache_key) { + metrics::GLOBAL + .transform_cache_hits + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return media_response( + method, + cached, + format.mime(), + headers.get(header::RANGE).and_then(|v| v.to_str().ok()), + passthrough_disposition_header(disposition, format.mime()), + ); + } + metrics::GLOBAL + .transform_cache_misses + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let coalescer_deadline = deadline_instant(options.deadline_ms); + let transformed = match app + .coalescer + .run_once_until(cache_key.clone(), coalescer_deadline, || { + let app = app.clone(); + let data = data.clone(); + let options = options.clone(); + async move { + coalesced_work_result(run_transform(&app, data, options).await) + .map(|media| media.bytes) + } + }) + .await + { + Ok(bytes) => bytes, + Err(CoalescerError::RequestTimeout) => { + return text_with_reason( + StatusCode::GATEWAY_TIMEOUT, + "Gateway Timeout", + "coalescer_timeout_svg_rasterize", + ); + } + Err(CoalescerError::WorkFailed) => { + return text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "svg_rasterize_failed", + cache_identity, + ); + } + }; + app.transform_cache.put(cache_key, transformed.clone()); + media_response( + method, + transformed, + format.mime(), + headers.get(header::RANGE).and_then(|v| v.to_str().ok()), + passthrough_disposition_header(disposition, format.mime()), + ) +} + fn passthrough_head_response( content_type: &str, total_len: usize, @@ -1890,6 +2048,7 @@ async fn serve_stored_with_override( enum TransformRoute { Attachment, External, + Stored, } struct ServeBytesRequest<'a> { @@ -1915,40 +2074,24 @@ async fn serve_bytes_or_transform(app: &Arc, request: ServeBytesReques headers, } = request; let animated = animated_param(params, false); - let wants_transform = params.contains_key("width") - || params.contains_key("height") - || params.contains_key("format") - || params.contains_key("quality") - || animated; - let content_type = if content_type_is_trustworthy(&content_type) { + let sniffed_prefix = mime::sniff(&data[..data.len().min(8192)]); + let content_type = if sniffed_prefix.mime == "image/svg+xml" { + "image/svg+xml".to_owned() + } else if content_type_is_trustworthy(&content_type) { content_type } else { mime::detect(&data[..data.len().min(8192)], filename, Some(&content_type)) }; + let source_is_svg = is_svg_content_type(&content_type) + || image_extension_from_filename(filename) == Some(AssetExtension::Svg); + let wants_transform = source_is_svg + || params.contains_key("width") + || params.contains_key("height") + || params.contains_key("format") + || params.contains_key("quality") + || animated; let requested_download = bool_param(params, "download", false); - if content_type.eq_ignore_ascii_case("image/svg+xml") { - let sanitized = match svg::sanitize(&data) { - Ok(bytes) => bytes, - Err(_) => { - return text_with_reason( - StatusCode::BAD_REQUEST, - "Bad Request", - "svg_sanitize_failed", - ); - } - }; - let disposition = - content_disposition_header("image/svg+xml", requested_download, Some(filename)); - return media_response( - method, - Bytes::from(sanitized), - "image/svg+xml", - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - Some(disposition), - ); - } - if !wants_transform { let disposition = content_disposition_header(&content_type, requested_download, Some(filename)); @@ -2098,6 +2241,9 @@ async fn serve_bytes_or_transform(app: &Arc, request: ServeBytesReques image_extension_from_filename(filename).unwrap_or(AssetExtension::Webp) } TransformRoute::External => external_default_output_extension(filename, &content_type), + TransformRoute::Stored => { + image_extension_from_filename(filename).unwrap_or(AssetExtension::Webp) + } }; let requested_format = explicit_requested_format.unwrap_or(default_out_ext); let requested_supported_format = output_format::coerce_unsupported_format(requested_format); @@ -2752,6 +2898,11 @@ fn content_type_is_trustworthy(content_type: &str) -> bool { ) } +fn is_svg_content_type(content_type: &str) -> bool { + mime::normalize(Some(content_type)) + .is_some_and(|value| value.eq_ignore_ascii_case("image/svg+xml")) +} + fn extension_from_mime(content_type: &str) -> Option { match mime::normalize(Some(content_type))? { "image/jpeg" => Some(AssetExtension::Jpeg), @@ -2784,7 +2935,10 @@ fn transform_response_content_type( out_ext: AssetExtension, fallback_content_type: &str, ) -> &str { - if explicit_out_ext.is_some() || out_ext != requested_out_ext { + if explicit_out_ext.is_some() + || out_ext != requested_out_ext + || is_svg_content_type(fallback_content_type) + { out_ext.mime() } else { fallback_content_type @@ -2806,9 +2960,10 @@ fn transform_cache_key(input: TransformCacheKeyInput<'_>) -> String { let prefix = match input.route { TransformRoute::Attachment => "attachment", TransformRoute::External => "external", + TransformRoute::Stored => "stored", }; let identity = match input.route { - TransformRoute::Attachment => input.cache_identity.to_owned(), + TransformRoute::Attachment | TransformRoute::Stored => input.cache_identity.to_owned(), TransformRoute::External => sha256_hex(input.cache_identity.as_bytes()), }; format!( @@ -3020,6 +3175,60 @@ mod tests { assert_eq!(None, parse_dimension(Some("999999999"))); } + #[test] + fn metadata_base64_svg_detection_uses_bytes_or_filename() { + let svg_bytes = InputData { + data: Bytes::from_static(br#""#), + filename: "upload.bin".to_owned(), + }; + assert!(metadata_input_is_svg(&svg_bytes)); + + let svg_filename = InputData { + data: Bytes::from_static(b"not svg"), + filename: "icons/logo.svg".to_owned(), + }; + assert!(metadata_input_is_svg(&svg_filename)); + + let png_filename = InputData { + data: Bytes::from_static(b"not svg"), + filename: "icons/logo.png".to_owned(), + }; + assert!(!metadata_input_is_svg(&png_filename)); + } + + #[test] + fn replace_image_extension_only_changes_last_path_segment() { + assert_eq!( + "avatars/user.icon.webp", + replace_image_extension("avatars/user.icon.svg", AssetExtension::Webp) + ); + assert_eq!( + "avatars.v1/user.webp", + replace_image_extension("avatars.v1/user", AssetExtension::Webp) + ); + } + + #[tokio::test] + async fn metadata_base64_svg_rasterizes_to_webp_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = + upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901"); + let app = test_app_state(cfg); + let input = InputData { + data: Bytes::from_static( + br#""#, + ), + filename: "icons/logo.svg".to_owned(), + }; + let raster = match rasterize_metadata_svg(&app, input).await { + Ok(raster) => raster, + Err(response) => panic!("unexpected status {}", response.status()), + }; + + assert_eq!("icons/logo.webp", raster.filename); + assert_eq!("image/webp", mime::sniff(&raster.data).mime); + } + #[test] fn passthrough_content_type_preserves_non_media_metadata() { let head = HeadResult { @@ -3692,6 +3901,15 @@ mod tests { "image/heic" ) ); + assert_eq!( + "image/webp", + transform_response_content_type( + None, + AssetExtension::Webp, + AssetExtension::Webp, + "image/svg+xml; charset=utf-8" + ) + ); assert_eq!( "image/gif", transform_response_content_type( diff --git a/fluxer_media_proxy/src/svg.rs b/fluxer_media_proxy/src/svg.rs deleted file mode 100644 index 2349bafb0..000000000 --- a/fluxer_media_proxy/src/svg.rs +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::io::Cursor; - -#[derive(Debug, thiserror::Error)] -pub enum SvgError { - #[error("svg sanitization failed")] - Sanitize, -} - -const MAX_SANITIZED_BYTES: usize = 16 * 1024 * 1024; - -pub fn sanitize(input: &[u8]) -> Result, SvgError> { - let filter = svg_hush::Filter::new(); - let mut out: Vec = Vec::with_capacity(input.len()); - filter - .filter(Cursor::new(input), &mut out) - .map_err(|_| SvgError::Sanitize)?; - if out.len() > MAX_SANITIZED_BYTES { - return Err(SvgError::Sanitize); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn drops_inline_script_elements() { - let svg = br#" - - - "#; - let cleaned = sanitize(svg).expect("sanitize ok"); - let text = std::str::from_utf8(&cleaned).unwrap(); - assert!(!text.contains(" - - "#; - let cleaned = sanitize(svg).expect("sanitize ok"); - let text = std::str::from_utf8(&cleaned).unwrap(); - assert!(!text.to_ascii_lowercase().contains("onload")); - assert!(!text.to_ascii_lowercase().contains("onclick")); - } - - #[test] - fn neutralizes_javascript_hrefs() { - let svg = br#" - - - - "#; - if let Ok(cleaned) = sanitize(svg) { - let text = std::str::from_utf8(&cleaned).unwrap(); - assert!( - !text.to_ascii_lowercase().contains("javascript:"), - "javascript: scheme survived sanitization: {text}" - ); - } - } - - #[test] - fn preserves_basic_shapes_and_paths() { - let svg = br##" - - - "##; - let cleaned = sanitize(svg).expect("sanitize ok"); - let text = std::str::from_utf8(&cleaned).unwrap(); - assert!(text.contains("this is < not valid