use std::collections::BTreeMap; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use platform_oss::LegacyAssetPrefix; const DEFAULT_IMAGE_MIME: &str = "image/jpeg"; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedImageAssetImageFormat { pub(crate) mime_type: String, pub(crate) extension: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedImageAssetDataUrl { pub(crate) format: GeneratedImageAssetImageFormat, pub(crate) bytes: Vec, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct GeneratedImageAssetMetadataInput { pub(crate) asset_kind: Option, pub(crate) owner_user_id: Option, pub(crate) entity_kind: Option, pub(crate) entity_id: Option, pub(crate) slot: Option, pub(crate) provider: Option, pub(crate) task_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedImageAssetStoragePaths { pub(crate) object_key: String, pub(crate) legacy_public_path: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum GeneratedImageAssetHelperError { InvalidDataUrl, UnsupportedEncoding, DecodeBase64(String), InvalidFileName, } pub(crate) fn normalize_generated_image_asset_mime( raw_content_type: impl AsRef, ) -> GeneratedImageAssetImageFormat { let mime_type = raw_content_type .as_ref() .split(';') .next() .map(str::trim) .unwrap_or(DEFAULT_IMAGE_MIME) .to_ascii_lowercase(); match mime_type.as_str() { "image/png" => image_format("image/png", "png"), "image/webp" => image_format("image/webp", "webp"), "image/gif" => image_format("image/gif", "gif"), "image/jpeg" | "image/jpg" | "application/octet-stream" | "" => { image_format(DEFAULT_IMAGE_MIME, "jpg") } _ => image_format(DEFAULT_IMAGE_MIME, "jpg"), } } pub(crate) fn decode_generated_image_asset_data_url( raw_data_url: &str, ) -> Result { let (metadata, encoded) = raw_data_url .trim() .split_once(',') .ok_or(GeneratedImageAssetHelperError::InvalidDataUrl)?; let metadata = metadata.trim(); if !metadata.to_ascii_lowercase().starts_with("data:") { return Err(GeneratedImageAssetHelperError::InvalidDataUrl); } let header = &metadata["data:".len()..]; let mut parts = header .split(';') .map(str::trim) .filter(|part| !part.is_empty()); let mime_type = parts.next().unwrap_or(DEFAULT_IMAGE_MIME); let is_base64 = parts.any(|part| part.eq_ignore_ascii_case("base64")); if !is_base64 { return Err(GeneratedImageAssetHelperError::UnsupportedEncoding); } let bytes = BASE64_STANDARD .decode(encoded.trim()) .map_err(|error| GeneratedImageAssetHelperError::DecodeBase64(error.to_string()))?; Ok(GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(mime_type), bytes, }) } pub(crate) fn build_generated_image_asset_storage_paths( prefix: LegacyAssetPrefix, path_segments: &[String], file_name: &str, ) -> Result { let file_name = sanitize_generated_image_asset_file_name(file_name)?; let mut parts = vec![prefix.as_str().to_string()]; parts.extend( path_segments .iter() .map(|segment| sanitize_generated_image_asset_path_segment(segment)) .filter(|segment| !segment.is_empty()), ); parts.push(file_name); let object_key = parts.join("/"); Ok(GeneratedImageAssetStoragePaths { legacy_public_path: format!("/{object_key}"), object_key, }) } pub(crate) fn build_generated_image_asset_metadata( input: GeneratedImageAssetMetadataInput, ) -> BTreeMap { let mut metadata = BTreeMap::new(); insert_optional_metadata(&mut metadata, "asset_kind", input.asset_kind); insert_optional_metadata(&mut metadata, "owner_user_id", input.owner_user_id); insert_optional_metadata(&mut metadata, "entity_kind", input.entity_kind); insert_optional_metadata(&mut metadata, "entity_id", input.entity_id); insert_optional_metadata(&mut metadata, "slot", input.slot); insert_optional_metadata(&mut metadata, "provider", input.provider); insert_optional_metadata(&mut metadata, "task_id", input.task_id); metadata } pub(crate) fn merge_generated_image_asset_metadata( base: BTreeMap, overlay: BTreeMap, ) -> BTreeMap { let mut merged = BTreeMap::new(); for (key, value) in base.into_iter().chain(overlay) { let key = key.trim(); let value = value.trim(); if key.is_empty() || value.is_empty() { continue; } merged.insert(key.to_string(), value.to_string()); } merged } fn image_format(mime_type: &str, extension: &str) -> GeneratedImageAssetImageFormat { GeneratedImageAssetImageFormat { mime_type: mime_type.to_string(), extension: extension.to_string(), } } fn insert_optional_metadata( metadata: &mut BTreeMap, key: &str, value: Option, ) { if let Some(value) = value { let value = value.trim(); if !value.is_empty() { metadata.insert(key.to_string(), value.to_string()); } } } fn sanitize_generated_image_asset_path_segment(raw: &str) -> String { raw.trim() .trim_matches('/') .chars() .map(|ch| match ch { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', ch if ch.is_control() => '-', ch => ch, }) .collect::() .trim_matches('-') .to_string() } fn sanitize_generated_image_asset_file_name( raw: &str, ) -> Result { let sanitized = sanitize_generated_image_asset_path_segment(raw); if sanitized.is_empty() || sanitized == "." || sanitized == ".." || sanitized.contains('/') { return Err(GeneratedImageAssetHelperError::InvalidFileName); } Ok(sanitized) } #[cfg(test)] mod generated_image_assets_tests { use std::collections::BTreeMap; use super::*; #[test] fn generated_image_assets_normalize_mime_and_extension() { assert_eq!( normalize_generated_image_asset_mime(" image/PNG; charset=utf-8 "), image_format("image/png", "png") ); assert_eq!( normalize_generated_image_asset_mime("image/jpg"), image_format("image/jpeg", "jpg") ); assert_eq!( normalize_generated_image_asset_mime("text/plain"), image_format("image/jpeg", "jpg") ); } #[test] fn generated_image_assets_decode_data_url_base64() { let decoded = decode_generated_image_asset_data_url("data:image/webp;base64,aGVsbG8=") .expect("data url should decode"); assert_eq!(decoded.format, image_format("image/webp", "webp")); assert_eq!(decoded.bytes, b"hello"); } #[test] fn generated_image_assets_reject_non_base64_data_url() { assert_eq!( decode_generated_image_asset_data_url("data:image/png,hello").unwrap_err(), GeneratedImageAssetHelperError::UnsupportedEncoding ); } #[test] fn generated_image_assets_build_object_key_and_legacy_path() { let paths = build_generated_image_asset_storage_paths( LegacyAssetPrefix::BigFishAssets, &[" world/001 ".to_string(), "slot:cover".to_string()], " image.png ", ) .expect("paths should build"); assert_eq!( paths.object_key, "generated-big-fish-assets/world-001/slot-cover/image.png" ); assert_eq!( paths.legacy_public_path, "/generated-big-fish-assets/world-001/slot-cover/image.png" ); } #[test] fn generated_image_assets_merge_metadata_trims_and_overlay_wins() { let base = BTreeMap::from([ ("asset_kind".to_string(), " old ".to_string()), ("empty".to_string(), " ".to_string()), ]); let overlay = BTreeMap::from([ ("asset_kind".to_string(), "cover".to_string()), (" task_id ".to_string(), " task-1 ".to_string()), ]); assert_eq!( merge_generated_image_asset_metadata(base, overlay), BTreeMap::from([ ("asset_kind".to_string(), "cover".to_string()), ("task_id".to_string(), "task-1".to_string()), ]) ); } #[test] fn generated_image_assets_build_metadata_omits_blank_values() { let metadata = build_generated_image_asset_metadata(GeneratedImageAssetMetadataInput { asset_kind: Some(" scene ".to_string()), owner_user_id: Some("".to_string()), entity_kind: Some("world".to_string()), entity_id: None, slot: Some(" cover ".to_string()), provider: Some("dashscope".to_string()), task_id: Some(" task-1 ".to_string()), }); assert_eq!(metadata.get("asset_kind"), Some(&"scene".to_string())); assert_eq!(metadata.get("owner_user_id"), None); assert_eq!(metadata.get("slot"), Some(&"cover".to_string())); assert_eq!(metadata.get("task_id"), Some(&"task-1".to_string())); } }