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