use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use time::OffsetDateTime;
use crate::error::{CoreError, Result};
pub const HASH_LEN: usize = 16;
pub const ALLOWED_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetRef {
pub url: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetInfo {
pub name: String,
pub url: String,
pub ext: String,
pub bytes: u64,
#[serde(with = "time::serde::rfc3339")]
pub modified: OffsetDateTime,
}
pub fn normalize_ext(ext: &str) -> Result<&'static str> {
let e = ext.trim().trim_start_matches('.').to_ascii_lowercase();
let allowed = ALLOWED_EXTS
.iter()
.copied()
.find(|a| *a == e)
.ok_or_else(|| CoreError::AssetRejected(format!("unsupported extension: .{e}")))?;
Ok(allowed)
}
pub fn asset_name(bytes: &[u8], ext: &str) -> String {
let hex = blake3::hash(bytes).to_hex();
let stem = &hex.as_str()[..HASH_LEN];
format!("{stem}.{ext}")
}
pub fn valid_name(name: &str) -> bool {
let Some((stem, ext)) = name.split_once('.') else {
return false;
};
stem.len() == HASH_LEN
&& stem.bytes().all(|b| b.is_ascii_hexdigit())
&& ALLOWED_EXTS.contains(&ext)
&& !name.contains('/')
&& !name.contains('\\')
}
pub fn mime_for_ext(ext: &str) -> &'static str {
match ext {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "application/octet-stream",
}
}
pub fn ext_of(name: &str) -> Option<&'static str> {
let ext = name.split('.').nth(1)?;
ALLOWED_EXTS.iter().copied().find(|a| *a == ext)
}
pub fn refs_in_body(body: &str) -> HashSet<String> {
let mut out = HashSet::new();
let mut rest = body;
while let Some(start) = rest.find("oximg://") {
rest = &rest[start + "oximg://".len()..];
if let Some(after) = rest.strip_prefix("localhost/") {
rest = after;
}
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '.'))
.unwrap_or(rest.len());
let candidate = &rest[..end];
if valid_name(candidate) {
out.insert(candidate.to_string());
}
rest = &rest[end..];
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_rejects_unknown() {
assert_eq!(normalize_ext("PNG").unwrap(), "png");
assert!(normalize_ext("heic").is_err());
assert!(normalize_ext("").is_err());
}
#[test]
fn name_round_trips_validation() {
let bytes = b"hello";
let name = asset_name(bytes, "png");
assert!(valid_name(&name));
assert_eq!(ext_of(&name), Some("png"));
assert_eq!(name.split('.').next().unwrap().len(), HASH_LEN);
}
#[test]
fn valid_name_rejects_traversal() {
assert!(!valid_name("../etc/passwd"));
assert!(!valid_name("abc.png"));
assert!(!valid_name("deadbeefdeadbeef.exe"));
assert!(!valid_name("deadbeefdeadbeef.png/../../x"));
assert!(valid_name("deadbeefdeadbeef.png"));
}
#[test]
fn refs_extract_markdown_and_bare() {
let body = "see  and \
oximg://localhost/cafef00dcafef00d.gif trailing";
let refs = refs_in_body(body);
assert_eq!(refs.len(), 2);
assert!(refs.contains("deadbeefdeadbeef.png"));
assert!(refs.contains("cafef00dcafef00d.gif"));
}
#[test]
fn refs_ignore_width_fragment_and_query() {
let body = "";
let refs = refs_in_body(body);
assert!(refs.contains("deadbeefdeadbeef.png"));
assert_eq!(refs.len(), 1);
}
#[test]
fn mime_mapping() {
assert_eq!(mime_for_ext("png"), "image/png");
assert_eq!(mime_for_ext("jpeg"), "image/jpeg");
assert_eq!(mime_for_ext("webp"), "image/webp");
}
#[test]
fn dedup_is_content_addressed() {
let bytes = b"identical";
assert_eq!(asset_name(bytes, "png"), asset_name(bytes, "png"));
}
}