1use serde::{Deserialize, Serialize};
14use std::collections::HashSet;
15
16use time::OffsetDateTime;
17
18use crate::error::{CoreError, Result};
19
20pub const HASH_LEN: usize = 16;
24
25pub const ALLOWED_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AssetRef {
34 pub url: String,
35 pub name: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct AssetInfo {
41 pub name: String,
42 pub url: String,
43 pub ext: String,
44 pub bytes: u64,
45 #[serde(with = "time::serde::rfc3339")]
46 pub modified: OffsetDateTime,
47}
48
49pub fn normalize_ext(ext: &str) -> Result<&'static str> {
52 let e = ext.trim().trim_start_matches('.').to_ascii_lowercase();
53 let allowed = ALLOWED_EXTS
54 .iter()
55 .copied()
56 .find(|a| *a == e)
57 .ok_or_else(|| CoreError::AssetRejected(format!("unsupported extension: .{e}")))?;
58 Ok(allowed)
59}
60
61pub fn asset_name(bytes: &[u8], ext: &str) -> String {
63 let hex = blake3::hash(bytes).to_hex();
64 let stem = &hex.as_str()[..HASH_LEN];
65 format!("{stem}.{ext}")
66}
67
68pub fn valid_name(name: &str) -> bool {
72 let Some((stem, ext)) = name.split_once('.') else {
73 return false;
74 };
75 stem.len() == HASH_LEN
76 && stem.bytes().all(|b| b.is_ascii_hexdigit())
77 && ALLOWED_EXTS.contains(&ext)
78 && !name.contains('/')
79 && !name.contains('\\')
80}
81
82pub fn mime_for_ext(ext: &str) -> &'static str {
84 match ext {
85 "png" => "image/png",
86 "jpg" | "jpeg" => "image/jpeg",
87 "gif" => "image/gif",
88 "webp" => "image/webp",
89 _ => "application/octet-stream",
90 }
91}
92
93pub fn ext_of(name: &str) -> Option<&'static str> {
96 let ext = name.split('.').nth(1)?;
97 ALLOWED_EXTS.iter().copied().find(|a| *a == ext)
98}
99
100pub fn refs_in_body(body: &str) -> HashSet<String> {
105 let mut out = HashSet::new();
106 let mut rest = body;
107 while let Some(start) = rest.find("oximg://") {
108 rest = &rest[start + "oximg://".len()..];
109 if let Some(after) = rest.strip_prefix("localhost/") {
113 rest = after;
114 }
115 let end = rest
120 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '.'))
121 .unwrap_or(rest.len());
122 let candidate = &rest[..end];
123 if valid_name(candidate) {
124 out.insert(candidate.to_string());
125 }
126 rest = &rest[end..];
127 }
128 out
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn normalize_rejects_unknown() {
137 assert_eq!(normalize_ext("PNG").unwrap(), "png");
138 assert!(normalize_ext("heic").is_err());
139 assert!(normalize_ext("").is_err());
140 }
141
142 #[test]
143 fn name_round_trips_validation() {
144 let bytes = b"hello";
145 let name = asset_name(bytes, "png");
146 assert!(valid_name(&name));
147 assert_eq!(ext_of(&name), Some("png"));
148 assert_eq!(name.split('.').next().unwrap().len(), HASH_LEN);
149 }
150
151 #[test]
152 fn valid_name_rejects_traversal() {
153 assert!(!valid_name("../etc/passwd"));
154 assert!(!valid_name("abc.png"));
155 assert!(!valid_name("deadbeefdeadbeef.exe"));
156 assert!(!valid_name("deadbeefdeadbeef.png/../../x"));
157 assert!(valid_name("deadbeefdeadbeef.png"));
158 }
159
160 #[test]
161 fn refs_extract_markdown_and_bare() {
162 let body = "see  and \
163 oximg://localhost/cafef00dcafef00d.gif trailing";
164 let refs = refs_in_body(body);
165 assert_eq!(refs.len(), 2);
166 assert!(refs.contains("deadbeefdeadbeef.png"));
167 assert!(refs.contains("cafef00dcafef00d.gif"));
168 }
169
170 #[test]
171 fn refs_ignore_width_fragment_and_query() {
172 let body = "";
174 let refs = refs_in_body(body);
175 assert!(refs.contains("deadbeefdeadbeef.png"));
176 assert_eq!(refs.len(), 1);
177 }
178
179 #[test]
180 fn mime_mapping() {
181 assert_eq!(mime_for_ext("png"), "image/png");
182 assert_eq!(mime_for_ext("jpeg"), "image/jpeg");
183 assert_eq!(mime_for_ext("webp"), "image/webp");
184 }
185
186 #[test]
187 fn dedup_is_content_addressed() {
188 let bytes = b"identical";
189 assert_eq!(asset_name(bytes, "png"), asset_name(bytes, "png"));
190 }
191}