Skip to main content

docs_pipeline/
embeds.rs

1//! Rich-media embed rendering.
2//!
3//! Renders whitelisted third-party embeds (YouTube, Figma, GitHub Gists,
4//! CodePen, tweets) into sandboxed iframes. Untrusted domains are blocked.
5
6use std::sync::LazyLock;
7
8static WHITELISTED_DOMAINS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
9    vec![
10        "www.youtube.com",
11        "youtube.com",
12        "youtu.be",
13        "www.figma.com",
14        "figma.com",
15        "gist.github.com",
16        "codepen.io",
17        "cdpn.io",
18        "platform.twitter.com",
19        "syndication.twitter.com",
20        "x.com",
21        "www.x.com",
22    ]
23});
24
25#[allow(dead_code)]
26const MAX_EMBEDS: usize = 10;
27
28/// Check whether a URL's domain is on the embed whitelist.
29pub fn is_domain_whitelisted(url: &str) -> bool {
30    WHITELISTED_DOMAINS.iter().any(|d| url.contains(d))
31}
32
33/// Count embed occurrences in content (skipping code blocks).
34pub fn count_embeds(content: &str) -> usize {
35    let mut count = 0;
36    let mut in_code_block = false;
37    for line in content.lines() {
38        if line.trim_start().starts_with("```") {
39            in_code_block = !in_code_block;
40            continue;
41        }
42        if in_code_block {
43            continue;
44        }
45        for alt in ["youtube", "figma", "gist", "codepen", "tweet"] {
46            let prefix = format!("![{}](", alt);
47            let mut rest = line;
48            while let Some(pos) = rest.find(&prefix) {
49                let after = &rest[pos + prefix.len()..];
50                if let Some(close) = after.find(')') {
51                    let url = &after[..close];
52                    if !url.trim().is_empty() {
53                        count += 1;
54                    }
55                    rest = &after[close + 1..];
56                } else {
57                    break;
58                }
59            }
60        }
61    }
62    count
63}
64
65/// Render a YouTube embed iframe for a video ID or URL.
66pub fn render_youtube(video_id: &str) -> String {
67    let video_id = video_id.trim();
68    if video_id.is_empty() {
69        return "<div class=\"embed-error\">YouTube embed: empty video ID</div>".to_string();
70    }
71    if !is_domain_whitelisted(video_id) && video_id.contains('/') {
72        return "<div class=\"embed-error\">YouTube embed blocked: untrusted URL</div>".to_string();
73    }
74    let id = video_id.split('/').next_back().unwrap_or(video_id);
75    format!(
76        r#"<div class="embed-youtube" data-video-id="{id}"><iframe src="https://www.youtube.com/embed/{id}" width="100%" height="360" frameborder="0" allowfullscreen loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe></div>"#,
77        id = html_escape(id)
78    )
79}
80
81/// Render a Figma embed iframe for a whitelisted Figma URL.
82pub fn render_figma(url: &str) -> String {
83    let url = url.trim();
84    if !is_domain_whitelisted(url) {
85        return "<div class=\"embed-error\">Figma embed blocked: untrusted URL</div>".to_string();
86    }
87    format!(
88        r#"<div class="embed-figma"><iframe src="https://www.figma.com/embed?embed_host=share&url={url}" width="100%" height="450" frameborder="0" allowfullscreen loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe></div>"#,
89        url = url_encode(url)
90    )
91}
92
93/// Render a GitHub Gist embed iframe for a whitelisted gist URL.
94pub fn render_gist(url: &str) -> String {
95    let url = url.trim();
96    if !is_domain_whitelisted(url) {
97        return "<div class=\"embed-error\">Gist embed blocked: untrusted URL</div>".to_string();
98    }
99    let raw = if url.ends_with(".js") {
100        url.to_string()
101    } else {
102        format!("{}.js", url)
103    };
104    format!(
105        r#"<div class="embed-gist"><iframe src="{raw}" width="100%" height="300" frameborder="0" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe></div>"#,
106        raw = html_escape(&raw)
107    )
108}
109
110/// Render a CodePen embed iframe for a whitelisted CodePen URL.
111pub fn render_codepen(url: &str) -> String {
112    let url = url.trim();
113    if !is_domain_whitelisted(url) {
114        return "<div class=\"embed-error\">CodePen embed blocked: untrusted URL</div>".to_string();
115    }
116    let embed_url = url
117        .replace("codepen.io/", "codepen.io/embed/")
118        .replace("cdpn.io/", "cdpn.io/embed/");
119    let embed_url = if !embed_url.contains("/embed/") {
120        format!("{}/embed/", url.trim_end_matches('/'))
121    } else {
122        embed_url
123    };
124    format!(
125        r#"<div class="embed-codepen"><iframe src="{embed_url}" width="100%" height="400" frameborder="0" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe></div>"#,
126        embed_url = html_escape(&embed_url)
127    )
128}
129
130/// Render a tweet embed blockquote for a whitelisted tweet URL.
131pub fn render_tweet(url: &str) -> String {
132    let url = url.trim();
133    if !is_domain_whitelisted(url) {
134        return "<div class=\"embed-error\">Tweet embed blocked: untrusted URL</div>".to_string();
135    }
136    format!(
137        r#"<div class="embed-tweet"><blockquote class="twitter-tweet" data-tweet-url="{url}"><a href="{url}">View Tweet</a></blockquote></div>"#,
138        url = html_escape(url)
139    )
140}
141
142fn html_escape(s: &str) -> String {
143    s.replace('&', "&amp;")
144        .replace('"', "&quot;")
145        .replace('<', "&lt;")
146        .replace('>', "&gt;")
147}
148
149fn url_encode(s: &str) -> String {
150    s.replace(' ', "%20")
151        .replace('"', "%22")
152        .replace('#', "%23")
153}
154
155/// Dispatch an embed render by `![alt](url)` alt name.
156///
157/// Returns `None` for unrecognized embed types.
158pub fn render_embed(alt: &str, url: &str) -> Option<String> {
159    Some(match alt {
160        "youtube" => render_youtube(url),
161        "figma" => render_figma(url),
162        "gist" => render_gist(url),
163        "codepen" => render_codepen(url),
164        "tweet" => render_tweet(url),
165        _ => return None,
166    })
167}
168
169/// CSP policy string for documents with embeds.
170pub fn embed_csp_policy() -> &'static str {
171    concat!(
172        "frame-src https://www.youtube.com https://youtube.com https://figma.com https://www.figma.com ",
173        "https://gist.github.com https://codepen.io https://cdpn.io ",
174        "https://platform.twitter.com https://syndication.twitter.com https://x.com https://www.x.com; ",
175        "script-src 'self' 'unsafe-inline' https://platform.twitter.com;"
176    )
177}
178
179#[cfg(test)]
180mod tests {
181    #![allow(clippy::unwrap_used)]
182    use super::*;
183
184    #[test]
185    fn test_youtube_render() {
186        let html = render_youtube("dQw4w9WgXcQ");
187        assert!(html.contains("embed-youtube"));
188        assert!(html.contains("youtube.com/embed/dQw4w9WgXcQ"));
189        assert!(html.contains("loading=\"lazy\""));
190        assert!(html.contains("sandbox=\"allow-scripts allow-same-origin\""));
191    }
192
193    #[test]
194    fn test_youtube_render_with_url() {
195        let html = render_youtube("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
196        assert!(html.contains("dQw4w9WgXcQ"));
197    }
198
199    #[test]
200    fn test_figma_render() {
201        let html = render_figma("https://www.figma.com/file/abc123");
202        assert!(html.contains("embed-figma"));
203        assert!(html.contains("figma.com/embed"));
204        assert!(html.contains("loading=\"lazy\""));
205    }
206
207    #[test]
208    fn test_gist_render() {
209        let html = render_gist("https://gist.github.com/user/abc123");
210        assert!(html.contains("embed-gist"));
211        assert!(html.contains("gist.github.com/user/abc123.js"));
212        assert!(html.contains("loading=\"lazy\""));
213    }
214
215    #[test]
216    fn test_codepen_render() {
217        let html = render_codepen("https://codepen.io/user/pen/abc123");
218        assert!(html.contains("embed-codepen"));
219        assert!(html.contains("codepen.io"));
220        assert!(html.contains("loading=\"lazy\""));
221    }
222
223    #[test]
224    fn test_tweet_render() {
225        let html = render_tweet("https://x.com/user/status/123456");
226        assert!(html.contains("embed-tweet"));
227        assert!(html.contains("twitter-tweet"));
228    }
229
230    #[test]
231    fn test_untrusted_domain_blocked() {
232        let html = render_youtube("https://evil.com/steal");
233        assert!(html.contains("blocked"));
234        assert!(!html.contains("<iframe"));
235
236        let html = render_figma("https://evil.com/fake");
237        assert!(html.contains("blocked"));
238
239        let html = render_gist("https://evil.com/fake");
240        assert!(html.contains("blocked"));
241
242        let html = render_codepen("https://evil.com/fake");
243        assert!(html.contains("blocked"));
244
245        let html = render_tweet("https://evil.com/fake");
246        assert!(html.contains("blocked"));
247    }
248
249    #[test]
250    fn test_domain_whitelist() {
251        assert!(is_domain_whitelisted("https://www.youtube.com/watch?v=123"));
252        assert!(is_domain_whitelisted("https://youtube.com/watch?v=123"));
253        assert!(is_domain_whitelisted("https://www.figma.com/file/abc"));
254        assert!(is_domain_whitelisted("https://gist.github.com/user/123"));
255        assert!(is_domain_whitelisted("https://codepen.io/user/pen/abc"));
256        assert!(is_domain_whitelisted("https://x.com/user/status/123"));
257        assert!(!is_domain_whitelisted("https://evil.com/malicious"));
258    }
259
260    #[test]
261    fn test_max_embed_count() {
262        let content = (0..15)
263            .map(|i| format!("![youtube](vid{})", i))
264            .collect::<Vec<_>>()
265            .join("\n");
266        assert_eq!(count_embeds(&content), 15);
267    }
268
269    #[test]
270    fn test_count_embeds_skips_code_blocks() {
271        let content = "![youtube](abc)\n```\n![youtube](xyz)\n```\n![figma](url)";
272        assert_eq!(count_embeds(content), 2);
273    }
274
275    #[test]
276    fn test_max_embeds_enforced() {
277        let mut embeds = Vec::new();
278        for i in 0..15 {
279            let url = format!("https://www.youtube.com/watch?v={}", i);
280            let count = embeds.len();
281            if count < MAX_EMBEDS {
282                embeds.push(render_youtube(&url));
283            }
284        }
285        assert_eq!(embeds.len(), MAX_EMBEDS);
286    }
287
288    #[test]
289    fn test_render_embed_dispatch() {
290        let html = render_embed("youtube", "abc123").unwrap();
291        assert!(html.contains("embed-youtube"));
292        assert!(render_embed("unknown", "abc123").is_none());
293    }
294
295    #[test]
296    fn test_csp_policy() {
297        let csp = embed_csp_policy();
298        assert!(csp.contains("frame-src"));
299        assert!(csp.contains("youtube.com"));
300        assert!(csp.contains("figma.com"));
301        assert!(csp.contains("twitter.com"));
302    }
303
304    #[test]
305    fn test_html_escape() {
306        assert_eq!(html_escape("a&b\"c<d>e"), "a&amp;b&quot;c&lt;d&gt;e");
307    }
308
309    #[test]
310    fn test_empty_url_returns_error() {
311        let html = render_youtube("");
312        assert!(
313            html.contains("empty") || html.contains("error"),
314            "Expected error for empty URL, got: {}",
315            html
316        );
317    }
318}