mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
fix(media-proxy): rasterize svg responses (#1298)
This commit is contained in:
Generated
-26
@@ -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"
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AppState>,
|
||||
input: InputData,
|
||||
) -> Result<InputData, Response> {
|
||||
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<AppState>,
|
||||
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<AppState>, 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<AppState>, 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<AssetExtension> {
|
||||
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#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#),
|
||||
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#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="red"/></svg>"#,
|
||||
),
|
||||
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(
|
||||
|
||||
@@ -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<Vec<u8>, SvgError> {
|
||||
let filter = svg_hush::Filter::new();
|
||||
let mut out: Vec<u8> = 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#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
|
||||
<script>alert(1)</script>
|
||||
<rect width="10" height="10" fill="red"/>
|
||||
</svg>"#;
|
||||
let cleaned = sanitize(svg).expect("sanitize ok");
|
||||
let text = std::str::from_utf8(&cleaned).unwrap();
|
||||
assert!(!text.contains("<script"), "script tag survived: {text}");
|
||||
assert!(text.contains("<rect"), "rect element missing: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_event_handler_attributes() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
|
||||
<rect width="1" height="1" onclick="alert(2)"/>
|
||||
</svg>"#;
|
||||
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#"<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a xlink:href="javascript:alert(1)" href="javascript:alert(2)">
|
||||
<rect width="1" height="1"/>
|
||||
</a>
|
||||
</svg>"#;
|
||||
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##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M0 0 L10 10 Z" fill="#abc"/>
|
||||
<circle cx="5" cy="5" r="3"/>
|
||||
</svg>"##;
|
||||
let cleaned = sanitize(svg).expect("sanitize ok");
|
||||
let text = std::str::from_utf8(&cleaned).unwrap();
|
||||
assert!(text.contains("<svg"));
|
||||
assert!(text.contains("<path"));
|
||||
assert!(text.contains("<circle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_xml() {
|
||||
let result = sanitize(b"<svg>this is < not valid </svg");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user