use base64::Engine;
use std::path::Path;
use crate::error::AppError;
pub enum Kind {
Image,
Audio,
Video,
}
impl Kind {
fn label(&self) -> &'static str {
match self {
Kind::Image => "image",
Kind::Audio => "audio",
Kind::Video => "video",
}
}
}
const IMAGE_MAX_BYTES: u64 = 30 * 1024 * 1024;
const AUDIO_MAX_BYTES: u64 = 15 * 1024 * 1024;
const BASE64_RATIO: f64 = 0.75;
const AUDIO_ALLOWED_SUBTYPES: &[&str] = &["wav", "x-wav", "mpeg", "mp3"];
pub fn resolve(reference: &str, kind: Kind) -> Result<String, AppError> {
if reference.starts_with("http://") || reference.starts_with("https://") {
return Ok(reference.to_string());
}
if reference.starts_with("asset://") {
return Ok(reference.to_string());
}
if reference.starts_with("data:") {
return validate_data_url(reference, &kind);
}
let path = Path::new(reference);
if !path.exists() {
return Err(AppError::InvalidInput(format!(
"not a URL and not an existing file: {reference}"
)));
}
match kind {
Kind::Video => Err(AppError::InvalidInput(format!(
"video input requires a public URL -- local path not supported: {reference}. \
Upload the mp4/mov to a CDN or pre-signed URL first."
))),
Kind::Image => encode_to_data_url(path, IMAGE_MAX_BYTES, "image", None),
Kind::Audio => encode_to_data_url(
path,
AUDIO_MAX_BYTES,
"audio",
Some(AUDIO_ALLOWED_SUBTYPES),
),
}
}
fn validate_data_url(reference: &str, kind: &Kind) -> Result<String, AppError> {
let rest = reference
.strip_prefix("data:")
.ok_or_else(|| AppError::InvalidInput(format!("not a data URL: {reference}")))?;
let (mime_and_enc, payload) = rest.split_once(',').ok_or_else(|| {
AppError::InvalidInput(format!(
"malformed data URL: missing comma separator ({reference})"
))
})?;
let parts: Vec<&str> = mime_and_enc.split(';').collect();
let mime = parts
.first()
.copied()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let is_base64 = parts.iter().any(|p| p.trim().eq_ignore_ascii_case("base64"));
if mime.is_empty() {
return Err(AppError::InvalidInput(format!(
"data URL is missing a MIME type ({kind})",
kind = kind.label()
)));
}
match kind {
Kind::Video => {
return Err(AppError::InvalidInput(format!(
"video input requires a public URL -- data: URLs are not accepted for video ({mime})"
)));
}
Kind::Image => {
if !mime.starts_with("image/") {
return Err(AppError::InvalidInput(format!(
"image input must have an image/* MIME type, got `{mime}`"
)));
}
}
Kind::Audio => {
if !mime.starts_with("audio/") {
return Err(AppError::InvalidInput(format!(
"audio input must have an audio/* MIME type, got `{mime}`"
)));
}
let subtype = mime.split('/').nth(1).unwrap_or("");
if !AUDIO_ALLOWED_SUBTYPES.contains(&subtype) {
return Err(AppError::InvalidInput(format!(
"audio MIME `{mime}` not supported -- API accepts wav or mp3"
)));
}
}
}
if is_base64 {
let approx_bytes = (payload.len() as f64 * BASE64_RATIO) as u64;
let max = match kind {
Kind::Image => IMAGE_MAX_BYTES,
Kind::Audio => AUDIO_MAX_BYTES,
Kind::Video => unreachable!(),
};
if approx_bytes > max {
return Err(AppError::InvalidInput(format!(
"data URL payload is ~{approx_bytes} bytes, exceeds {kind} limit of {max} bytes",
kind = kind.label()
)));
}
}
Ok(reference.to_string())
}
fn encode_to_data_url(
path: &Path,
max_bytes: u64,
prefix: &str,
allowed_subtypes: Option<&[&str]>,
) -> Result<String, AppError> {
let meta = std::fs::metadata(path)?;
if meta.len() > max_bytes {
return Err(AppError::InvalidInput(format!(
"{} too large: {} bytes > {} bytes. Upload as a URL instead.",
path.display(),
meta.len(),
max_bytes
)));
}
let mime = mime_guess::from_path(path)
.first_or_octet_stream()
.essence_str()
.to_lowercase();
if !mime.starts_with(prefix) {
return Err(AppError::InvalidInput(format!(
"{} does not look like a {prefix} file (mime: {mime})",
path.display()
)));
}
if let Some(allowed) = allowed_subtypes {
let subtype = mime.split('/').nth(1).unwrap_or("");
if !allowed.contains(&subtype) {
return Err(AppError::InvalidInput(format!(
"{} has MIME `{mime}` which is not accepted by the API -- allowed: {}",
path.display(),
allowed.join(", ")
)));
}
}
let bytes = std::fs::read(path)?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:{mime};base64,{b64}"))
}