// SPDX-License-Identifier: AGPL-3.0-or-later mod failure; mod input; pub(in crate::server) use failure::MediaFailure; pub(in crate::server) use input::{ MediaInput, MediaInputLimit, STORED_SNIFF_PREFIX_BYTES, load_media_input, read_sniff_prefix, spool_stored_media, }; use crate::{ constants::{self, AssetExtension}, image_quality::ImageQuality, image_transform::AnimationMode, media_process, mime, nsfw::NSFWPolicy, output_format::OutputFormat, server::{ format_policy::image_extension_from_filename, state::AppState, transform::execution::{deadline_instant, run_transform, transform_error_is_timeout}, }, }; use bytes::Bytes; use input::LoadedMediaInput; pub(in crate::server) struct MetadataOutput { pub(in crate::server) metadata: serde_json::Value, pub(in crate::server) data: Option, } pub(in crate::server) async fn resolve_metadata( app: &AppState, input: MediaInput, scan_nsfw: bool, include_data: bool, ) -> Result { let mut input = load_media_input(app, input, MediaInputLimit::INTERNAL_REQUEST).await?; if include_data && input.data.len() > constants::MAX_MEDIA_PROXY_BYTES { return Err(MediaFailure::MetadataDecodedTooLarge); } if include_data && metadata_input_is_svg(&input) { input = rasterize_metadata_svg(app, input).await?; } let nsfw = if scan_nsfw { app.media.nsfw().policy() } else { app.media.nsfw().record_declined_scan(); NSFWPolicy::Disabled }; let runtime = app.media.transforms(); let options = media_process::MetadataOptions { placeholder: true, nsfw, deadline_ms: runtime.transform_deadline_ms(), }; let deadline = deadline_instant(options.deadline_ms); let media_limits = app.media.limits(); let transform_metrics = app.metrics.transform(); let data = input.data.clone(); let cost_data = input.data.clone(); let extraction_failed = |err: &dyn std::fmt::Debug| MediaFailure::MetadataExtractionFailed { detail: format!("filename={} err={err:?}", input.filename), }; let prepared = runtime .tasks() .run_native( deadline, move || Ok(media_process::metadata_cost(&cost_data, &media_limits)?), move || { Ok(media_process::prepare_metadata( &data, &options, &media_limits, &transform_metrics, )?) }, ) .await .map_err(|err| extraction_failed(&err))?; let json = media_process::finish_metadata(prepared, app.media.nsfw()) .await .map_err(|err| extraction_failed(&err))?; Ok(MetadataOutput { metadata: serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({})), data: include_data.then_some(input.data), }) } fn metadata_input_is_svg(input: &LoadedMediaInput) -> bool { mime::sniff(&input.data).mime == "image/svg+xml" || image_extension_from_filename(&input.filename) == Some(AssetExtension::Svg) } async fn rasterize_metadata_svg( app: &AppState, input: LoadedMediaInput, ) -> Result { let options = media_process::ImageOptions { width: Some(constants::SVG_RASTER_MAX_DIMENSION), height: Some(constants::SVG_RASTER_MAX_DIMENSION), format: OutputFormat::WebP, quality: ImageQuality::Lossless, animation: AnimationMode::Static, deadline_ms: app.media.transforms().transform_deadline_ms(), ..Default::default() }; let filename = input.filename; match run_transform(app.media.transforms(), input.data, options).await { Ok(media) => Ok(LoadedMediaInput { data: media.bytes.into(), filename: replace_image_extension(&filename, AssetExtension::Webp), }), Err(err) if transform_error_is_timeout(&err) => { Err(MediaFailure::MetadataSvgRasterizeTimeout { detail: filename }) } Err(err) => Err(MediaFailure::MetadataSvgRasterizeFailed { detail: format!("filename={filename} err={err:?}"), }), } } 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()), } } #[cfg(test)] mod tests { use super::*; use crate::byte_budget::{BudgetedBytes, ByteBudget}; use crate::config::Config; use std::sync::Arc; fn test_app_state() -> Arc { let cfg = Config::load_from_iter([("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret")]) .expect("test config"); Arc::new(AppState::for_tests(cfg)) } #[test] fn metadata_base64_svg_detection_uses_bytes_or_filename() { let svg_bytes = LoadedMediaInput { data: Bytes::from_static(br#""#), filename: "upload.bin".to_owned(), }; assert!(metadata_input_is_svg(&svg_bytes)); let svg_filename = LoadedMediaInput { data: Bytes::from_static(b"not svg"), filename: "icons/logo.svg".to_owned(), }; assert!(metadata_input_is_svg(&svg_filename)); let png_filename = LoadedMediaInput { 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 app = test_app_state(); let input = LoadedMediaInput { data: Bytes::from_static( br#""#, ), filename: "icons/logo.svg".to_owned(), }; let raster = match rasterize_metadata_svg(&app, input).await { Ok(raster) => raster, Err(failure) => panic!("unexpected failure {}", failure.code()), }; assert_eq!("icons/logo.webp", raster.filename); assert_eq!("image/webp", mime::sniff(&raster.data).mime); } #[test] fn an_external_media_input_keeps_owing_the_shared_buffer_budget() { let budget = ByteBudget::new(16); let reservation = budget.try_reserve(16).expect("reserve at limit"); let data = input::retained_input_bytes(BudgetedBytes::budgeted( Bytes::from_static(b"payload"), reservation, )); assert_eq!(b"payload", data.as_ref()); assert!( budget.try_reserve(1).is_none(), "a resident external input still owes the external buffer budget" ); drop(data); assert_eq!( 16, budget .try_reserve(16) .expect("dropping the input releases its reservation") .amount() ); } }