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.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 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 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 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 ProviderEmbed {
97 embed_url: url.to_string(),
98 allow: "",
99 sandbox: "",
100 allowfullscreen: false,
101 provider_name: "",
102 }
103}
104
105pub 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(¶ms, &provider.embed_url, assets)
151}
152
153fn 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
172fn 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
184fn 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..])), 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#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn empty_snapshot() -> AssetSnapshot {
324 AssetSnapshot::new()
325 }
326
327 #[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 #[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 #[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 #[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 #[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 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 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 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 let out = synthesize_url_embed_html(
589 "https://example.com/x?a=1&b=2",
590 &PotholeContent::Empty,
591 &empty_snapshot(),
592 );
593 assert!(
595 out.contains(r#"src="https://example.com/x?a=1&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 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}