1use 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#[derive(Debug, Clone, PartialEq)]
21pub struct ProviderEmbed {
22 pub embed_url: String,
24 pub allow: &'static str,
26 pub sandbox: &'static str,
28 pub allowfullscreen: bool,
30 pub provider_name: &'static str,
32}
33
34pub 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 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 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 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 ProviderEmbed {
100 embed_url: url.to_string(),
101 allow: "",
102 sandbox: "",
103 allowfullscreen: false,
104 provider_name: "",
105 }
106}
107
108pub 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(¶ms, &provider.embed_url, assets)
154}
155
156fn 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
175fn 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
187fn 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 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#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn empty_snapshot() -> AssetSnapshot {
330 AssetSnapshot::new()
331 }
332
333 #[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 #[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 #[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 #[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 #[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 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 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 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 let out = synthesize_url_embed_html(
595 "https://example.com/x?a=1&b=2",
596 &PotholeContent::Empty,
597 &empty_snapshot(),
598 );
599 assert!(
601 out.contains(r#"src="https://example.com/x?a=1&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 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}