Skip to main content

moss_core/render/
url_embed.rs

1//! URL embed synthesizer — provider-aware `<iframe>` for external URLs.
2//!
3//! `![[https://...]]` wikilink embeds are routed here. `detect_provider`
4//! matches the URL against known platforms (YouTube, Vimeo, CodePen) and
5//! returns a [`ProviderEmbed`] carrying the canonical embed URL plus the
6//! correct `allow`/`sandbox`/`allowfullscreen` attributes. Unknown URLs
7//! fall through to a generic passthrough.
8//!
9//! `synthesize_url_embed_html` combines the provider result with the
10//! pothole params (sizing, alias) and delegates final HTML synthesis to
11//! [`crate::render::iframe::synthesize_iframe_html`].
12
13use crate::asset_snapshot::AssetSnapshot;
14use crate::render::iframe::synthesize_iframe_html;
15use crate::resolve::embed_renderer::Sizing;
16use crate::resolve::title_params::TitleParams;
17use crate::resolve::wikilink_dispatch::PotholeContent;
18
19/// Provider-specific iframe configuration for a matched URL.
20#[derive(Debug, Clone, PartialEq)]
21pub struct ProviderEmbed {
22    /// Canonical embed URL (may differ from the input URL).
23    pub embed_url: String,
24    /// Value for the `allow=` attribute. Empty string → attribute omitted.
25    pub allow: &'static str,
26    /// Value for the `sandbox=` attribute. Empty string → attribute omitted.
27    pub sandbox: &'static str,
28    /// Whether to emit the boolean `allowfullscreen` attribute.
29    pub allowfullscreen: bool,
30    /// Lowercase provider name emitted as `data-provider="…"`. Empty → omitted.
31    pub provider_name: &'static str,
32}
33
34/// Detect which provider (if any) serves this URL and return the appropriate
35/// [`ProviderEmbed`] configuration.
36///
37/// Matched providers: `youtube`, `vimeo`, `codepen`.
38/// Everything else returns a generic passthrough with empty policy strings.
39pub fn detect_provider(url: &str) -> ProviderEmbed {
40    let after_scheme = url
41        .strip_prefix("https://")
42        .or_else(|| url.strip_prefix("http://"))
43        .unwrap_or(url);
44
45    let (host, path_and_query) = match after_scheme.find('/') {
46        Some(i) => (&after_scheme[..i], &after_scheme[i..]),
47        None => (after_scheme, ""),
48    };
49
50    let bare_host = host.strip_prefix("www.").unwrap_or(host);
51
52    // YouTube
53    if bare_host == "youtube.com" || bare_host == "youtu.be" {
54        if let Some((id, start)) = extract_youtube_id(bare_host, path_and_query) {
55            let embed_url = match start {
56                Some(t) => format!("https://www.youtube.com/embed/{}?start={}", id, t),
57                None => format!("https://www.youtube.com/embed/{}", id),
58            };
59            return ProviderEmbed {
60                embed_url,
61                allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
62                sandbox: "",
63                allowfullscreen: true,
64                provider_name: "youtube",
65            };
66        }
67    }
68
69    // Vimeo
70    if bare_host == "vimeo.com" || bare_host == "player.vimeo.com" {
71        if let Some(embed_url) = extract_vimeo_embed_url(bare_host, path_and_query) {
72            return ProviderEmbed {
73                embed_url,
74                allow: "autoplay; fullscreen; picture-in-picture",
75                sandbox: "",
76                allowfullscreen: true,
77                provider_name: "vimeo",
78            };
79        }
80    }
81
82    // CodePen
83    if bare_host == "codepen.io" {
84        if let Some(embed_url) = extract_codepen_embed_url(path_and_query) {
85            return ProviderEmbed {
86                embed_url,
87                allow: "clipboard-read; clipboard-write",
88                sandbox: "allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-presentation",
89                allowfullscreen: true,
90                provider_name: "codepen",
91            };
92        }
93    }
94
95    // Generic fallback
96    ProviderEmbed {
97        embed_url: url.to_string(),
98        allow: "",
99        sandbox: "",
100        allowfullscreen: false,
101        provider_name: "",
102    }
103}
104
105/// Synthesize `<iframe>` HTML for an external URL embed.
106///
107/// 1. Detects the provider and rewrites the URL to the canonical embed form.
108/// 2. Merges pothole sizing/alias into `TitleParams`.
109/// 3. Delegates to [`synthesize_iframe_html`].
110/// 4. Injects `data-provider="…"` for known providers.
111pub fn synthesize_url_embed_html(
112    url: &str,
113    pothole: &PotholeContent,
114    assets: &AssetSnapshot,
115) -> String {
116    let provider = detect_provider(url);
117    let mut params = TitleParams::default();
118
119    match pothole {
120        PotholeContent::Empty => {}
121        PotholeContent::WidthToken { width, rest_alias } => {
122            params.insert("data-width", *width);
123            if !rest_alias.is_empty() {
124                apply_alias_to_params(rest_alias.as_str(), &mut params);
125            }
126        }
127        PotholeContent::Alias(alias) => {
128            apply_alias_to_params(alias.as_str(), &mut params);
129        }
130        PotholeContent::Params(kv) => {
131            for (k, v) in &kv.params {
132                params.insert(k.clone(), v.clone());
133            }
134        }
135    }
136
137    if !provider.allow.is_empty() {
138        params.insert("allow", provider.allow);
139    }
140    if !provider.sandbox.is_empty() {
141        params.insert("sandbox", provider.sandbox);
142    }
143    if provider.allowfullscreen {
144        params.insert("allowfullscreen", "true");
145    }
146    if !provider.provider_name.is_empty() {
147        params.insert("data-provider", provider.provider_name);
148    }
149
150    synthesize_iframe_html(&params, &provider.embed_url, assets)
151}
152
153// ---------------------------------------------------------------------------
154// Private helpers
155// ---------------------------------------------------------------------------
156
157fn apply_alias_to_params(alias: &str, params: &mut TitleParams) {
158    match Sizing::parse(alias) {
159        Some(Sizing::Width(w)) => {
160            params.insert("width", w.to_css());
161        }
162        Some(Sizing::Box(w, h)) => {
163            params.insert("width", w.to_css());
164            params.insert("height", h.to_css());
165        }
166        None => {
167            params.insert("title", alias);
168        }
169    }
170}
171
172/// Find the value of `key` in a `&`-separated query string (e.g. `"v=abc&t=30"`).
173fn find_query_param<'a>(query: &'a str, key: &str) -> Option<&'a str> {
174    for param in query.split('&') {
175        if let Some(val) = param.strip_prefix(key) {
176            if let Some(val) = val.strip_prefix('=') {
177                return Some(val);
178            }
179        }
180    }
181    None
182}
183
184/// Parse a YouTube timestamp (`t=` or `start=`) from a query string.
185/// Strips a trailing `s` suffix (e.g. `30s` → `"30"`). Returns `None` if
186/// not present or not purely numeric after stripping.
187fn parse_youtube_timestamp(query: &str) -> Option<String> {
188    let raw = find_query_param(query, "t")
189        .or_else(|| find_query_param(query, "start"))?;
190    let digits = raw.strip_suffix('s').unwrap_or(raw);
191    if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
192        return None;
193    }
194    Some(digits.to_string())
195}
196
197fn extract_youtube_id(host: &str, path_and_query: &str) -> Option<(String, Option<String>)> {
198    if host == "youtu.be" {
199        let trimmed = path_and_query.trim_start_matches('/');
200        let id = trimmed.split(['/', '?', '#']).next()?;
201        let id = validate_youtube_id(id)?.to_string();
202        let start = path_and_query
203            .find('?')
204            .and_then(|i| parse_youtube_timestamp(&path_and_query[i + 1..]));
205        return Some((id, start));
206    }
207
208    if let Some(rest) = path_and_query.strip_prefix("/embed/") {
209        let id = rest.split(['/', '?', '#']).next()?;
210        let id = validate_youtube_id(id)?.to_string();
211        let start = rest
212            .find('?')
213            .and_then(|i| parse_youtube_timestamp(&rest[i + 1..]));
214        return Some((id, start));
215    }
216
217    if let Some(rest) = path_and_query.strip_prefix("/shorts/") {
218        let id = rest.split(['/', '?', '#']).next()?;
219        let id = validate_youtube_id(id)?.to_string();
220        let start = rest
221            .find('?')
222            .and_then(|i| parse_youtube_timestamp(&rest[i + 1..]));
223        return Some((id, start));
224    }
225
226    if let Some(rest) = path_and_query.strip_prefix("/live/") {
227        let id = rest.split(['/', '?', '#']).next()?;
228        let id = validate_youtube_id(id)?.to_string();
229        let start = rest
230            .find('?')
231            .and_then(|i| parse_youtube_timestamp(&rest[i + 1..]));
232        return Some((id, start));
233    }
234
235    if path_and_query.starts_with("/watch") {
236        let query_start = path_and_query.find('?').map(|i| i + 1)?;
237        let query = &path_and_query[query_start..];
238        for param in query.split('&') {
239            if let Some(id) = param.strip_prefix("v=") {
240                let id = id.split(['&', '#']).next().unwrap_or(id);
241                if let Some(id) = validate_youtube_id(id) {
242                    let start = parse_youtube_timestamp(query);
243                    return Some((id.to_string(), start));
244                }
245            }
246        }
247    }
248
249    None
250}
251
252fn validate_youtube_id(id: &str) -> Option<&str> {
253    if id.len() == 11 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
254        Some(id)
255    } else {
256        None
257    }
258}
259
260fn extract_vimeo_embed_url(bare_host: &str, path_and_query: &str) -> Option<String> {
261    if bare_host == "player.vimeo.com" {
262        return Some(format!("https://player.vimeo.com{}", path_and_query));
263    }
264
265    let (path, query) = match path_and_query.find('?') {
266        Some(i) => (&path_and_query[..i], Some(&path_and_query[i..])), // includes the '?'
267        None => (path_and_query, None),
268    };
269    let mut segments = path.trim_start_matches('/').split('/');
270
271    let id_str = segments.next()?;
272    if id_str.is_empty() || !id_str.chars().all(|c| c.is_ascii_digit()) {
273        return None;
274    }
275
276    let hash = segments
277        .next()
278        .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
279
280    Some(match hash {
281        Some(h) => format!("https://player.vimeo.com/video/{}?h={}", id_str, h),
282        None => match query {
283            Some(q) => format!("https://player.vimeo.com/video/{}{}", id_str, q),
284            None => format!("https://player.vimeo.com/video/{}", id_str),
285        },
286    })
287}
288
289fn extract_codepen_embed_url(path_and_query: &str) -> Option<String> {
290    let path = path_and_query.split('?').next().unwrap_or(path_and_query);
291    let trimmed = path.trim_start_matches('/');
292    let mut parts = trimmed.splitn(3, '/');
293    let user = parts.next()?;
294    let kind = parts.next()?;
295    let slug = parts
296        .next()
297        .unwrap_or("")
298        .split('?')
299        .next()
300        .unwrap_or("")
301        .split('#')
302        .next()
303        .unwrap_or("");
304
305    if slug.is_empty() {
306        return None;
307    }
308
309    match kind {
310        "pen" | "embed" => Some(format!("https://codepen.io/{}/embed/{}", user, slug)),
311        _ => None,
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Tests
317// ---------------------------------------------------------------------------
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn empty_snapshot() -> AssetSnapshot {
324        AssetSnapshot::new()
325    }
326
327    // YouTube
328    #[test]
329    fn detect_provider_youtube_watch() {
330        let p = detect_provider("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
331        assert_eq!(p.embed_url, "https://www.youtube.com/embed/dQw4w9WgXcQ");
332        assert!(p.allow.contains("encrypted-media"), "allow: {}", p.allow);
333        assert!(p.allowfullscreen);
334        assert_eq!(p.provider_name, "youtube");
335        assert_eq!(p.sandbox, "");
336    }
337
338    #[test]
339    fn detect_provider_youtube_shortlink() {
340        let p = detect_provider("https://youtu.be/dQw4w9WgXcQ");
341        assert_eq!(p.embed_url, "https://www.youtube.com/embed/dQw4w9WgXcQ");
342        assert_eq!(p.provider_name, "youtube");
343    }
344
345    #[test]
346    fn detect_provider_youtube_shorts() {
347        let p = detect_provider("https://www.youtube.com/shorts/dQw4w9WgXcQ");
348        assert_eq!(p.embed_url, "https://www.youtube.com/embed/dQw4w9WgXcQ");
349        assert_eq!(p.provider_name, "youtube");
350    }
351
352    #[test]
353    fn detect_provider_youtube_live() {
354        let p = detect_provider("https://www.youtube.com/live/dQw4w9WgXcQ");
355        assert_eq!(p.embed_url, "https://www.youtube.com/embed/dQw4w9WgXcQ");
356        assert_eq!(p.provider_name, "youtube");
357    }
358
359    #[test]
360    fn detect_provider_youtube_already_embed() {
361        let p = detect_provider("https://www.youtube.com/embed/dQw4w9WgXcQ");
362        assert_eq!(p.embed_url, "https://www.youtube.com/embed/dQw4w9WgXcQ");
363        assert_eq!(p.provider_name, "youtube");
364    }
365
366    #[test]
367    fn detect_provider_youtube_no_www() {
368        let p = detect_provider("https://youtube.com/watch?v=dQw4w9WgXcQ");
369        assert_eq!(p.embed_url, "https://www.youtube.com/embed/dQw4w9WgXcQ");
370        assert_eq!(p.provider_name, "youtube");
371    }
372
373    // Vimeo
374    #[test]
375    fn detect_provider_vimeo_standard() {
376        let p = detect_provider("https://vimeo.com/123456789");
377        assert_eq!(p.embed_url, "https://player.vimeo.com/video/123456789");
378        assert!(p.allow.contains("fullscreen"), "allow: {}", p.allow);
379        assert!(p.allowfullscreen);
380        assert_eq!(p.provider_name, "vimeo");
381        assert_eq!(p.sandbox, "");
382    }
383
384    #[test]
385    fn detect_provider_vimeo_unlisted() {
386        let p = detect_provider("https://vimeo.com/123456789/abc123def");
387        assert_eq!(
388            p.embed_url,
389            "https://player.vimeo.com/video/123456789?h=abc123def"
390        );
391        assert_eq!(p.provider_name, "vimeo");
392    }
393
394    #[test]
395    fn detect_provider_vimeo_player_passthrough() {
396        let p = detect_provider("https://player.vimeo.com/video/123456789");
397        assert_eq!(p.embed_url, "https://player.vimeo.com/video/123456789");
398        assert_eq!(p.provider_name, "vimeo");
399    }
400
401    // CodePen
402    #[test]
403    fn detect_provider_codepen_pen_to_embed() {
404        let p = detect_provider("https://codepen.io/someuser/pen/abcDEF");
405        assert_eq!(p.embed_url, "https://codepen.io/someuser/embed/abcDEF");
406        assert!(
407            p.sandbox.contains("allow-scripts"),
408            "sandbox: {}",
409            p.sandbox
410        );
411        assert!(
412            p.allow.contains("clipboard-write"),
413            "allow: {}",
414            p.allow
415        );
416        assert!(p.allowfullscreen);
417        assert_eq!(p.provider_name, "codepen");
418    }
419
420    #[test]
421    fn detect_provider_codepen_embed_passthrough() {
422        let p = detect_provider("https://codepen.io/someuser/embed/abcDEF");
423        assert_eq!(p.embed_url, "https://codepen.io/someuser/embed/abcDEF");
424        assert_eq!(p.provider_name, "codepen");
425    }
426
427    // Generic fallback
428    #[test]
429    fn detect_provider_generic_https() {
430        let p = detect_provider("https://example.com/page");
431        assert_eq!(p.embed_url, "https://example.com/page");
432        assert_eq!(p.allow, "");
433        assert_eq!(p.sandbox, "");
434        assert!(!p.allowfullscreen);
435        assert_eq!(p.provider_name, "");
436    }
437
438    #[test]
439    fn detect_provider_generic_http() {
440        let p = detect_provider("http://example.com/page");
441        assert_eq!(p.embed_url, "http://example.com/page");
442        assert_eq!(p.provider_name, "");
443    }
444
445    #[test]
446    fn detect_provider_malformed_url() {
447        let p = detect_provider("https://");
448        assert_eq!(p.embed_url, "https://");
449        assert_eq!(p.provider_name, "");
450    }
451
452    // synthesize_url_embed_html
453    #[test]
454    fn synthesize_url_embed_full_youtube_shape() {
455        let out = synthesize_url_embed_html(
456            "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
457            &PotholeContent::Empty,
458            &empty_snapshot(),
459        );
460        assert!(
461            out.contains(r#"src="https://www.youtube.com/embed/dQw4w9WgXcQ""#),
462            "got: {out}"
463        );
464        assert!(out.contains(r#"class="moss-embed""#), "got: {out}");
465        assert!(out.contains(r#"data-type="iframe""#), "got: {out}");
466        assert!(out.contains(r#"data-provider="youtube""#), "got: {out}");
467        assert!(out.contains("allowfullscreen"), "got: {out}");
468        assert!(out.contains("allow="), "got: {out}");
469    }
470
471    #[test]
472    fn synthesize_url_embed_generic_no_provider_attr() {
473        let out = synthesize_url_embed_html(
474            "https://example.com/embed",
475            &PotholeContent::Empty,
476            &empty_snapshot(),
477        );
478        assert!(
479            out.contains(r#"src="https://example.com/embed""#),
480            "got: {out}"
481        );
482        assert!(
483            !out.contains("data-provider="),
484            "generic should have no data-provider, got: {out}"
485        );
486        assert!(
487            !out.contains("allow="),
488            "generic should have no allow, got: {out}"
489        );
490        assert!(
491            !out.contains("allowfullscreen"),
492            "generic should have no allowfullscreen, got: {out}"
493        );
494    }
495
496    #[test]
497    fn synthesize_url_embed_pothole_width_token() {
498        let out = synthesize_url_embed_html(
499            "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
500            &PotholeContent::WidthToken {
501                width: "wide",
502                rest_alias: String::new(),
503            },
504            &empty_snapshot(),
505        );
506        assert!(out.contains(r#"data-width="wide""#), "got: {out}");
507    }
508
509    #[test]
510    fn synthesize_url_embed_pothole_sizing() {
511        use crate::resolve::wikilink_dispatch::parse_pothole_params;
512        let pothole = parse_pothole_params("640x360");
513        let out = synthesize_url_embed_html(
514            "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
515            &pothole,
516            &empty_snapshot(),
517        );
518        assert!(out.contains(r#"width="640px""#), "got: {out}");
519        assert!(out.contains(r#"height="360px""#), "got: {out}");
520    }
521
522    #[test]
523    fn synthesize_url_embed_pothole_alias_becomes_title() {
524        let out = synthesize_url_embed_html(
525            "https://vimeo.com/123456789",
526            &PotholeContent::Alias("My video".to_string()),
527            &empty_snapshot(),
528        );
529        assert!(out.contains(r#"title="My video""#), "got: {out}");
530    }
531
532    #[test]
533    fn synthesize_url_embed_codepen_has_sandbox() {
534        let out = synthesize_url_embed_html(
535            "https://codepen.io/user/pen/abc",
536            &PotholeContent::Empty,
537            &empty_snapshot(),
538        );
539        assert!(out.contains("sandbox="), "got: {out}");
540        assert!(out.contains("allow-scripts"), "got: {out}");
541        assert!(out.contains(r#"data-provider="codepen""#), "got: {out}");
542    }
543
544    #[test]
545    fn detect_provider_youtube_watch_with_timestamp() {
546        let p = detect_provider("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42");
547        assert!(p.embed_url.contains("start=42"), "timestamp should be preserved, got: {}", p.embed_url);
548    }
549
550    #[test]
551    fn detect_provider_youtube_shortlink_with_timestamp() {
552        let p = detect_provider("https://youtu.be/dQw4w9WgXcQ?t=30");
553        assert!(p.embed_url.contains("start=30"), "got: {}", p.embed_url);
554    }
555
556    #[test]
557    fn detect_provider_youtube_invalid_id_falls_to_generic() {
558        // Short IDs (not 11 chars) should fall through to generic
559        let p = detect_provider("https://www.youtube.com/watch?v=short");
560        assert_eq!(p.provider_name, "", "short ID should not match youtube");
561        assert_eq!(p.embed_url, "https://www.youtube.com/watch?v=short");
562    }
563
564    #[test]
565    fn detect_provider_youtube_no_v_param_falls_to_generic() {
566        let p = detect_provider("https://www.youtube.com/watch");
567        assert_eq!(p.provider_name, "");
568    }
569
570    #[test]
571    fn synthesize_url_embed_alias_xss_is_escaped() {
572        // Alias text goes into title= attribute; HTML special chars must be escaped
573        let out = synthesize_url_embed_html(
574            "https://vimeo.com/123456789",
575            &PotholeContent::Alias(r#"My "video" <test>"#.to_string()),
576            &empty_snapshot(),
577        );
578        // The raw quote and angle bracket must not appear unescaped in the output
579        assert!(!out.contains(r#"title="My "video""#), "unescaped quote in title, got: {out}");
580        assert!(!out.contains("<test>"), "unescaped angle bracket in title, got: {out}");
581    }
582
583    #[test]
584    fn dispatch_generic_url_with_query_preserved_in_src() {
585        // Generic URL with query string: split_dest_url splits on ?, reassemble_url
586        // puts it back, detect_provider keeps it unchanged in embed_url, and
587        // synthesize_iframe_html HTML-escapes & → &amp; in the src attribute.
588        let out = synthesize_url_embed_html(
589            "https://example.com/x?a=1&b=2",
590            &PotholeContent::Empty,
591            &empty_snapshot(),
592        );
593        // & in query string must be escaped as &amp; in HTML attribute
594        assert!(
595            out.contains(r#"src="https://example.com/x?a=1&amp;b=2""#),
596            "query string must survive and be HTML-escaped, got: {out}"
597        );
598        assert!(!out.contains("data-provider="), "generic, got: {out}");
599    }
600
601    #[test]
602    fn detect_provider_youtube_timestamp_with_s_suffix() {
603        // YouTube links often use t=30s (with trailing 's' unit); should be
604        // normalised to ?start=30 (without the 's')
605        let p = detect_provider("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s");
606        assert!(p.embed_url.contains("start=30"), "s-suffix timestamp should be normalised, got: {}", p.embed_url);
607        assert!(!p.embed_url.contains("30s"), "raw 30s must not appear, got: {}", p.embed_url);
608    }
609}