1use regex::Regex;
2use std::sync::LazyLock;
3
4static EXTENSION_RE: LazyLock<Regex> = LazyLock::new(|| {
5 Regex::new(r"\.([0-9a-zA-Z]+)([?#].*)?$").expect("Invalid EXTENSION_RE regex pattern")
6});
7
8const AUDIO_EXTENSIONS: &[&str] = &["mp3", "wav", "ogg", "flac", "aac", "m4a", "webm"];
10
11#[derive(Debug, PartialEq, Default)]
12pub struct Audio {
13 pub url: String,
14 pub ext: Option<String>,
15 pub caption: Option<String>,
16}
17
18impl Audio {
19 pub fn from_url_and_title(url: &str, title: &str) -> Option<Self> {
21 let ext = Self::extension_from_url(url)?;
22 if AUDIO_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
23 Some(Self {
24 url: url.to_string(),
25 ext: Some(ext),
26 caption: if title.is_empty() {
27 None
28 } else {
29 Some(title.to_string())
30 },
31 })
32 } else {
33 None
34 }
35 }
36
37 pub fn to_mime_type(&self) -> String {
38 match self.ext.as_deref() {
39 Some("mp3") => "audio/mpeg".to_string(),
40 Some("m4a") => "audio/mp4".to_string(),
41 Some("ogg") => "audio/ogg".to_string(),
42 Some("wav") => "audio/wav".to_string(),
43 Some("flac") => "audio/flac".to_string(),
44 Some("aac") => "audio/aac".to_string(),
45 Some("webm") => "audio/webm".to_string(),
46 Some(ext) => format!("audio/{ext}"),
47 None => "audio/mpeg".to_string(),
48 }
49 }
50
51 pub fn to_html(&self, open_only: bool) -> String {
59 let mime_type = self.to_mime_type();
60 format!(
61 r#"
62 <figure class="audio-embed">
63 <audio controls preload="metadata">
64 <source src="{}" type="{}">
65 Your browser does not support the audio element.
66 </audio>
67 <figcaption>{}{}"#,
68 html_escape::encode_double_quoted_attribute(&self.url),
69 html_escape::encode_double_quoted_attribute(&mime_type),
70 self.caption
71 .as_deref()
72 .map(html_escape::encode_text)
73 .unwrap_or_default(),
74 if open_only { "" } else { Self::html_close() }
75 )
76 }
77
78 pub fn html_close() -> &'static str {
79 "</figcaption></figure>"
80 }
81
82 fn extension_from_url(url: &str) -> Option<String> {
83 EXTENSION_RE.captures(url).map(|cap| cap[1].to_string())
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn test_from_url_and_title_mp3() {
93 let audio = Audio::from_url_and_title("podcast.mp3", "Episode 1").unwrap();
94 assert_eq!(audio.url, "podcast.mp3");
95 assert_eq!(audio.ext.as_deref(), Some("mp3"));
96 assert_eq!(audio.caption.as_deref(), Some("Episode 1"));
97 }
98
99 #[test]
100 fn test_from_url_and_title_various_formats() {
101 for ext in &["mp3", "wav", "ogg", "flac", "aac", "m4a", "webm"] {
102 let url = format!("audio.{}", ext);
103 let audio = Audio::from_url_and_title(&url, "Test");
104 assert!(audio.is_some(), "Should recognize .{} files", ext);
105 }
106 }
107
108 #[test]
109 fn test_from_url_and_title_not_audio() {
110 assert!(Audio::from_url_and_title("image.png", "Not audio").is_none());
111 assert!(Audio::from_url_and_title("video.mp4", "Not audio").is_none());
112 assert!(Audio::from_url_and_title("document.pdf", "Not audio").is_none());
113 }
114
115 #[test]
116 fn test_from_url_and_title_empty_caption() {
117 let audio = Audio::from_url_and_title("track.mp3", "").unwrap();
118 assert!(audio.caption.is_none());
119 }
120
121 #[test]
122 fn test_to_mime_type() {
123 let cases = [
124 ("mp3", "audio/mpeg"),
125 ("m4a", "audio/mp4"),
126 ("ogg", "audio/ogg"),
127 ("wav", "audio/wav"),
128 ("flac", "audio/flac"),
129 ("aac", "audio/aac"),
130 ("webm", "audio/webm"),
131 ];
132 for (ext, expected_mime) in cases {
133 let audio = Audio {
134 url: format!("file.{}", ext),
135 ext: Some(ext.to_string()),
136 caption: None,
137 };
138 assert_eq!(audio.to_mime_type(), expected_mime);
139 }
140 }
141
142 #[test]
143 fn test_to_html() {
144 let audio = Audio {
145 url: "/audio/song.mp3".to_string(),
146 ext: Some("mp3".to_string()),
147 caption: Some("My Song".to_string()),
148 };
149 let html = audio.to_html(false);
150 assert!(html.contains("<audio controls"));
151 assert!(html.contains(r#"src="/audio/song.mp3""#));
152 assert!(html.contains(r#"type="audio/mpeg""#));
153 assert!(html.contains("<figcaption>My Song</figcaption>"));
154 }
155
156 #[test]
157 fn test_to_html_open_only() {
158 let audio = Audio {
159 url: "song.mp3".to_string(),
160 ext: Some("mp3".to_string()),
161 caption: Some("Test".to_string()),
162 };
163 let html = audio.to_html(true);
164 assert!(html.contains("<audio"));
165 assert!(!html.contains("</figcaption></figure>"));
166 }
167
168 #[test]
169 fn test_extension_from_url_with_query() {
170 let audio = Audio::from_url_and_title("song.mp3?token=abc", "Test").unwrap();
171 assert_eq!(audio.ext.as_deref(), Some("mp3"));
172 }
173
174 #[test]
175 fn test_case_insensitive_extension() {
176 assert!(Audio::from_url_and_title("song.MP3", "Test").is_some());
177 assert!(Audio::from_url_and_title("song.Mp3", "Test").is_some());
178 }
179
180 #[test]
183 fn test_to_html_escapes_hostile_url() {
184 let audio =
185 Audio::from_url_and_title(r#"a"onerror="alert(1)"b.mp3"#, "").expect("audio embed");
186 let html = audio.to_html(false);
187 assert!(
188 html.contains("""),
189 "double quotes must be escaped: {html}"
190 );
191 assert!(
194 html.contains(r#"src="a"onerror="alert(1)"b.mp3""#),
195 "src attribute must be fully escaped: {html}"
196 );
197 assert!(
198 !html.contains(r#""onerror=""#),
199 "must not emit an injected attribute: {html}"
200 );
201 }
202
203 #[test]
206 fn test_to_html_escapes_ampersand_in_url() {
207 let audio = Audio::from_url_and_title("/audio/Q&A-interview.mp3", "").expect("audio embed");
208 let html = audio.to_html(false);
209 assert!(
210 html.contains(r#"src="/audio/Q&A-interview.mp3""#),
211 "ampersand must be encoded: {html}"
212 );
213 }
214
215 #[test]
217 fn test_to_html_escapes_caption() {
218 let audio = Audio::from_url_and_title("song.mp3", "<script>alert(1)</script> & more")
219 .expect("audio embed");
220 let html = audio.to_html(false);
221 assert!(
222 html.contains("<script>alert(1)</script> & more"),
223 "caption must be escaped: {html}"
224 );
225 assert!(
226 !html.contains("<script>"),
227 "must not emit a raw script tag: {html}"
228 );
229 }
230
231 #[test]
233 fn test_to_html_ordinary_values_round_trip_unchanged() {
234 let audio = Audio::from_url_and_title("/audio/my-song_v2.mp3", "My Song").unwrap();
235 let html = audio.to_html(false);
236 assert!(html.contains(r#"src="/audio/my-song_v2.mp3""#));
237 assert!(html.contains(r#"type="audio/mpeg""#));
238 assert!(html.contains("<figcaption>My Song</figcaption>"));
239 assert!(!html.contains("&"), "nothing to escape here: {html}");
240 }
241}