292 lines
9.5 KiB
Rust
292 lines
9.5 KiB
Rust
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<u8>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub(crate) struct GeneratedImageAssetMetadataInput {
|
|
pub(crate) asset_kind: Option<String>,
|
|
pub(crate) owner_user_id: Option<String>,
|
|
pub(crate) entity_kind: Option<String>,
|
|
pub(crate) entity_id: Option<String>,
|
|
pub(crate) slot: Option<String>,
|
|
pub(crate) provider: Option<String>,
|
|
pub(crate) task_id: Option<String>,
|
|
}
|
|
|
|
#[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<str>,
|
|
) -> 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<GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError> {
|
|
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<GeneratedImageAssetStoragePaths, GeneratedImageAssetHelperError> {
|
|
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<String, String> {
|
|
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<String, String>,
|
|
overlay: BTreeMap<String, String>,
|
|
) -> BTreeMap<String, String> {
|
|
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<String, String>,
|
|
key: &str,
|
|
value: Option<String>,
|
|
) {
|
|
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::<String>()
|
|
.trim_matches('-')
|
|
.to_string()
|
|
}
|
|
|
|
fn sanitize_generated_image_asset_file_name(
|
|
raw: &str,
|
|
) -> Result<String, GeneratedImageAssetHelperError> {
|
|
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()));
|
|
}
|
|
}
|