Skip to main content

mbr_core/
audio.rs

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
8/// Audio file extensions we support
9const 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    /// Try to create an Audio from a URL if it has an audio file extension
20    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    /// Generate HTML for audio embedding
52    /// If open_only is true, leaves the figcaption unclosed for the markdown parser to add content
53    ///
54    /// The URL and MIME type are the raw markdown link destination and its
55    /// derived type (pulldown-cmark does not escape the destination, and
56    /// neither does `link_transform`), so both are escaped for double-quoted
57    /// attribute context. The caption is escaped for element-text context.
58    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    /// Regression: a link destination containing a double quote must not be
181    /// able to close the `src` attribute and inject new attributes.
182    #[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("&quot;"),
189            "double quotes must be escaped: {html}"
190        );
191        // The whole hostile destination must stay inside the src value; the
192        // injected text may appear only as escaped data, never as an attribute.
193        assert!(
194            html.contains(r#"src="a&quot;onerror=&quot;alert(1)&quot;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    /// Regression: a bare `&` in a filename is invalid in an attribute value
204    /// and must be encoded.
205    #[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&amp;A-interview.mp3""#),
211            "ampersand must be encoded: {html}"
212        );
213    }
214
215    /// Regression: captions land in element-text context and must be escaped.
216    #[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("&lt;script&gt;alert(1)&lt;/script&gt; &amp; 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    /// Escaping must not double-encode ordinary paths, MIME types, or captions.
232    #[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("&amp;"), "nothing to escape here: {html}");
240    }
241}