1use crate::errors::OembedError;
2use crate::media::MediaEmbed;
3use futures::StreamExt;
4use regex::Regex;
5use reqwest::Client;
6use scraper::{ElementRef, Html, Selector};
7use std::error::Error;
8use std::net::IpAddr;
9use std::sync::LazyLock;
10use std::time::Duration;
11use url::Url;
12
13const MAX_OEMBED_BODY_BYTES: usize = 512 * 1024;
16
17const MAX_REDIRECTS: usize = 5;
19
20static OEMBED_CLIENT: LazyLock<Client> = LazyLock::new(|| {
29 crate::http_client_builder()
30 .redirect(reqwest::redirect::Policy::none())
31 .build()
32 .expect("failed to build HTTP client")
33});
34
35fn is_public_ip(ip: IpAddr) -> bool {
39 match ip {
40 IpAddr::V4(v4) => {
41 !(v4.is_loopback()
42 || v4.is_unspecified()
43 || v4.is_private()
44 || v4.is_link_local()
45 || v4.is_broadcast())
46 }
47 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
48 Some(mapped) => is_public_ip(IpAddr::V4(mapped)),
50 None => {
51 !(v6.is_loopback()
52 || v6.is_unspecified()
53 || v6.is_unique_local()
54 || v6.is_unicast_link_local())
55 }
56 },
57 }
58}
59
60async fn check_url_target<F: Fn(IpAddr) -> bool>(url: &Url, check: &F) -> Result<(), OembedError> {
64 fn disallowed(detail: String) -> OembedError {
65 OembedError::DisallowedAddress { detail }
66 }
67 let host = url.host().ok_or_else(|| OembedError::NoHost {
68 url: url.to_string(),
69 })?;
70 match host {
71 url::Host::Ipv4(ip) => check(IpAddr::V4(ip))
72 .then_some(())
73 .ok_or_else(|| disallowed(ip.to_string())),
74 url::Host::Ipv6(ip) => check(IpAddr::V6(ip))
75 .then_some(())
76 .ok_or_else(|| disallowed(ip.to_string())),
77 url::Host::Domain(domain) => {
78 let port = url
79 .port_or_known_default()
80 .ok_or_else(|| OembedError::NoKnownPort {
81 url: url.to_string(),
82 })?;
83 let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((domain, port))
84 .await
85 .map_err(|source| OembedError::DnsResolveFailed {
86 domain: domain.to_string(),
87 source,
88 })?
89 .collect();
90 if addrs.is_empty() {
91 return Err(OembedError::NoAddressesResolved {
92 domain: domain.to_string(),
93 });
94 }
95 addrs
96 .iter()
97 .all(|addr| check(addr.ip()))
98 .then_some(())
99 .ok_or_else(|| disallowed(format!("resolved for host {domain}")))
100 }
101 }
102}
103
104fn format_error_chain(err: &dyn Error) -> String {
107 let mut chain = vec![err.to_string()];
108 let mut current = err.source();
109 while let Some(source) = current {
110 chain.push(source.to_string());
111 current = source.source();
112 }
113 chain.join(" -> ")
114}
115
116static HEAD_SELECTOR: LazyLock<Selector> =
119 LazyLock::new(|| Selector::parse("head").expect("Invalid head selector"));
120static TITLE_SELECTOR: LazyLock<Selector> =
121 LazyLock::new(|| Selector::parse("title").expect("Invalid title selector"));
122
123static OG_TITLE_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
125 Selector::parse("meta[property='og:title']").expect("Invalid og:title selector")
126});
127static OG_IMAGE_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
128 Selector::parse("meta[property='og:image']").expect("Invalid og:image selector")
129});
130static OG_DESCRIPTION_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
131 Selector::parse("meta[property='og:description']").expect("Invalid og:description selector")
132});
133
134static META_DESCRIPTION_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
136 Selector::parse("meta[name='description']").expect("Invalid meta description selector")
137});
138static FAVICON_SELECTOR: LazyLock<Selector> =
139 LazyLock::new(|| Selector::parse("link[rel='icon']").expect("Invalid favicon selector"));
140static ALT_FAVICON_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
141 Selector::parse("link[rel='alternate icon']").expect("Invalid alternate favicon selector")
142});
143
144const SUPPORTED_FAVICON_TYPES: &[&str] = &[
146 "image/png",
147 "image/jpeg",
148 "image/jpg",
149 "image/svg+xml",
150 "image/x-icon",
151 "image/vnd.microsoft.icon",
152];
153
154fn is_supported_favicon_type(type_attr: Option<&str>) -> bool {
156 type_attr.is_none_or(|t| SUPPORTED_FAVICON_TYPES.contains(&t))
157}
158
159fn extract_content(head: &ElementRef, selector: &Selector) -> Option<String> {
161 head.select(selector)
162 .next()
163 .and_then(|el| el.value().attr("content"))
164 .map(|s| html_escape::encode_text(s).to_string())
165}
166
167fn extract_content_raw(head: &ElementRef, selector: &Selector) -> Option<String> {
171 head.select(selector)
172 .next()
173 .and_then(|el| el.value().attr("content"))
174 .map(String::from)
175}
176
177fn extract_favicon(head: &ElementRef, selector: &Selector) -> Option<String> {
179 head.select(selector)
180 .find(|el| is_supported_favicon_type(el.value().attr("type")))
181 .and_then(|el| el.value().attr("href"))
182 .map(|s| s.to_string())
183}
184
185static GIPHY_MEDIA_RE: LazyLock<Regex> =
187 LazyLock::new(|| Regex::new(r"https?://(?:media\d*|i)\.giphy\.com/").unwrap());
188static GIPHY_PAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
189 Regex::new(r"https?://(?:www\.)?giphy\.com/gifs/(?:[^/]+-)?([a-zA-Z0-9]+)(?:\?.*)?$").unwrap()
190});
191
192static GIST_RE: LazyLock<Regex> =
194 LazyLock::new(|| Regex::new(r"https?://gist\.github\.com/").unwrap());
195
196#[derive(Default, Clone)]
197pub struct PageInfo {
198 pub url: String,
199 pub title: Option<String>,
201 pub description: Option<String>,
203 pub image: Option<String>,
205 pub embed_html: Option<String>, }
207
208impl PageInfo {
209 pub fn estimated_size(&self) -> usize {
214 std::mem::size_of::<Self>()
215 + self.url.len()
216 + self.title.as_ref().map_or(0, |s| s.len())
217 + self.description.as_ref().map_or(0, |s| s.len())
218 + self.image.as_ref().map_or(0, |s| s.len())
219 + self.embed_html.as_ref().map_or(0, |s| s.len())
220 }
221
222 fn giphy_embed(url: &str) -> Option<String> {
229 if GIPHY_MEDIA_RE.is_match(url) {
232 return Some(format!(
233 r#"<figure class="giphy-embed">
234 <img src="{}" alt="Giphy animation" loading="lazy" />
235 </figure>"#,
236 url
237 ));
238 }
239
240 if let Some(caps) = GIPHY_PAGE_RE.captures(url)
243 && let Some(id) = caps.get(1)
244 {
245 let gif_url = format!("https://media.giphy.com/media/{}/giphy.gif", id.as_str());
246 return Some(format!(
247 r#"<figure class="giphy-embed">
248 <img src="{}" alt="Giphy animation" loading="lazy" />
249 </figure>"#,
250 gif_url
251 ));
252 }
253
254 None
255 }
256
257 fn gist_embed(url: &str) -> Option<String> {
262 if GIST_RE.is_match(url) {
263 let script_url = if let Some(hash_pos) = url.find('#') {
265 let (path, fragment) = url.split_at(hash_pos);
266 format!("{}.js{}", path, fragment)
267 } else {
268 format!("{}.js", url)
269 };
270 return Some(format!(r#"<script src="{}"></script>"#, script_url));
271 }
272 None
273 }
274
275 pub fn local_embed(url: &str) -> Option<PageInfo> {
279 if let Some(embed_html) = Self::giphy_embed(url) {
281 return Some(PageInfo {
282 url: url.to_string(),
283 embed_html: Some(embed_html),
284 ..Default::default()
285 });
286 }
287
288 if let Some(embed_html) = Self::gist_embed(url) {
290 return Some(PageInfo {
291 url: url.to_string(),
292 embed_html: Some(embed_html),
293 ..Default::default()
294 });
295 }
296
297 if let Some(media) = MediaEmbed::from_bare_url(url) {
301 return Some(PageInfo {
302 url: url.to_string(),
303 embed_html: Some(media.to_html(false, true, false)),
304 ..Default::default()
305 });
306 }
307
308 None
309 }
310
311 pub async fn new_from_url(url: &str, timeout_ms: u64) -> Result<Self, OembedError> {
312 if let Some(info) = Self::local_embed(url) {
315 return Ok(info);
316 }
317
318 if timeout_ms == 0 {
320 return Ok(PageInfo {
322 url: url.to_string(),
323 ..Default::default()
324 });
325 }
326
327 match Self::fetch_page_info(url, Duration::from_millis(timeout_ms)).await {
331 Ok(info) => Ok(info),
332 Err(e) => {
333 tracing::warn!(
335 "Error fetching URL ({}) for enriched display: {}",
336 &url,
337 format_error_chain(&e)
338 );
339 Ok(PageInfo {
340 url: url.to_string(),
341 ..Default::default()
342 })
343 }
344 }
345 }
346
347 async fn fetch_page_info(url: &str, timeout: Duration) -> Result<Self, OembedError> {
351 Self::fetch_page_info_inner(url, timeout, is_public_ip).await
352 }
353
354 async fn fetch_page_info_inner<F: Fn(IpAddr) -> bool>(
361 url: &str,
362 timeout: Duration,
363 check: F,
364 ) -> Result<Self, OembedError> {
365 let mut current_url = Url::parse(url)?;
366 let mut redirects = 0;
367 let response = loop {
368 if !matches!(current_url.scheme(), "http" | "https") {
369 return Err(OembedError::NonHttpScheme {
370 url: current_url.to_string(),
371 });
372 }
373 check_url_target(¤t_url, &check).await?;
374
375 let response = OEMBED_CLIENT
382 .get(current_url.clone())
383 .timeout(timeout)
384 .send()
385 .await?;
386
387 let status = response.status().as_u16();
388 if matches!(status, 301 | 302 | 303 | 307 | 308) {
389 if redirects >= MAX_REDIRECTS {
390 return Err(OembedError::TooManyRedirects {
391 max: MAX_REDIRECTS,
392 url: url.to_string(),
393 });
394 }
395 redirects += 1;
396 let location = response
397 .headers()
398 .get(reqwest::header::LOCATION)
399 .ok_or_else(|| OembedError::RedirectWithoutLocation {
400 url: current_url.to_string(),
401 })?
402 .to_str()
403 .map_err(|_| OembedError::InvalidLocationHeader {
404 url: current_url.to_string(),
405 })?;
406 current_url = current_url.join(location)?;
408 continue;
409 }
410 break response;
411 };
412
413 if response
415 .headers()
416 .get("cf-mitigated")
417 .is_some_and(|v| v == "challenge")
418 {
419 return Err(OembedError::CloudflareChallenge);
420 }
421
422 if let Some(content_type) = response.headers().get(reqwest::header::CONTENT_TYPE) {
424 let content_type = content_type.to_str().unwrap_or_default();
425 if !content_type.contains("text/html")
426 && !content_type.contains("application/xhtml+xml")
427 {
428 return Err(OembedError::NonHtmlContentType {
429 content_type: content_type.to_string(),
430 url: current_url.to_string(),
431 });
432 }
433 }
434
435 let mut buf: Vec<u8> = Vec::new();
439 let mut stream = response.bytes_stream();
440 while let Some(chunk) = stream.next().await {
441 let chunk = chunk?;
442 let remaining = MAX_OEMBED_BODY_BYTES - buf.len();
443 if chunk.len() >= remaining {
444 buf.extend_from_slice(&chunk[..remaining]);
445 break;
446 }
447 buf.extend_from_slice(&chunk);
448 }
449 let body = String::from_utf8_lossy(&buf);
453 let document = Html::parse_document(&body);
454
455 let (title, description, image) = document
457 .select(&HEAD_SELECTOR)
458 .next()
459 .map(|head| {
460 let og_title = extract_content(&head, &OG_TITLE_SELECTOR);
462 let og_desc = extract_content(&head, &OG_DESCRIPTION_SELECTOR);
463 let og_image = extract_content_raw(&head, &OG_IMAGE_SELECTOR);
465
466 let title = og_title.or_else(|| {
468 head.select(&TITLE_SELECTOR)
469 .next()
470 .map(|el| el.text().collect::<String>())
471 .filter(|s| !s.is_empty())
472 .map(|s| html_escape::encode_text(&s).to_string())
473 });
474
475 let description =
476 og_desc.or_else(|| extract_content(&head, &META_DESCRIPTION_SELECTOR));
477
478 let image = og_image
479 .or_else(|| extract_favicon(&head, &FAVICON_SELECTOR))
480 .or_else(|| extract_favicon(&head, &ALT_FAVICON_SELECTOR));
481
482 (title, description, image)
483 })
484 .unwrap_or((None, None, None));
485
486 Ok(PageInfo {
487 url: url.to_string(),
488 title,
489 description,
490 image,
491 embed_html: None,
492 })
493 }
494
495 pub fn text(&self) -> String {
496 format!(
497 "{}: {}",
498 self.title.clone().unwrap_or("no title".to_string()),
499 self.url
500 )
501 }
502
503 pub fn html(&self) -> String {
504 if let Some(embed) = &self.embed_html {
506 return embed.clone();
507 }
508
509 if let Some(title) = self.title.clone() {
514 let img_tag = self
515 .image
516 .as_ref()
517 .map(|src| {
518 format!(
519 "<img src=\"{}\"/>",
520 html_escape::encode_double_quoted_attribute(src)
521 )
522 })
523 .unwrap_or_default();
524 format!(
525 "<article class='mbr-social-link-box'>
526 {}
527 <a href=\"{}\" class='mbr-social-link'>
528 <header>{}</header>
529 <p>{}</p>
530 </a>
531 </article>
532 ",
533 img_tag,
534 html_escape::encode_double_quoted_attribute(&self.url),
535 title,
536 self.description.as_deref().unwrap_or(""),
537 )
538 } else {
539 format!(
540 "<a href=\"{}\">{}</a>",
541 html_escape::encode_double_quoted_attribute(&self.url),
542 html_escape::encode_text(&self.url),
543 )
544 }
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use std::net::{Ipv4Addr, SocketAddr};
552 use std::sync::Arc;
553 use std::sync::atomic::{AtomicUsize, Ordering};
554 use tokio::io::{AsyncReadExt, AsyncWriteExt};
555 use tokio::net::TcpListener;
556
557 async fn spawn_mock_server(
562 build_response: impl FnOnce(SocketAddr) -> String,
563 ) -> (SocketAddr, Arc<AtomicUsize>) {
564 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
565 let addr = listener.local_addr().unwrap();
566 let response = build_response(addr);
567 let hits = Arc::new(AtomicUsize::new(0));
568 let hits_counter = hits.clone();
569 tokio::spawn(async move {
570 loop {
571 let Ok((mut stream, _)) = listener.accept().await else {
572 break;
573 };
574 hits_counter.fetch_add(1, Ordering::SeqCst);
575 let response = response.clone();
576 tokio::spawn(async move {
577 let mut buf = [0u8; 4096];
578 let _ = stream.read(&mut buf).await;
580 let _ = stream.write_all(response.as_bytes()).await;
581 let _ = stream.shutdown().await;
582 });
583 }
584 });
585 (addr, hits)
586 }
587
588 fn html_response(body: &str) -> String {
589 format!(
590 "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
591 body.len(),
592 body
593 )
594 }
595
596 #[test]
597 fn test_is_public_ip() {
598 for ip in [
599 "127.0.0.1",
600 "0.0.0.0",
601 "10.1.2.3",
602 "172.16.0.1",
603 "172.31.255.255",
604 "192.168.1.1",
605 "169.254.169.254",
606 "::1",
607 "::",
608 "fc00::1",
609 "fe80::1",
610 "::ffff:10.0.0.1",
611 ] {
612 assert!(
613 !is_public_ip(ip.parse().unwrap()),
614 "{ip} should be rejected"
615 );
616 }
617 for ip in ["93.184.216.34", "172.32.0.1", "2606:2800::1"] {
618 assert!(is_public_ip(ip.parse().unwrap()), "{ip} should be accepted");
619 }
620 }
621
622 #[tokio::test]
623 async fn test_fetch_rejects_loopback_literal() {
624 let (addr, hits) =
625 spawn_mock_server(|_| html_response("<html><head><title>x</title></head></html>"))
626 .await;
627 let url = format!("http://127.0.0.1:{}/", addr.port());
628 let result = PageInfo::fetch_page_info(&url, Duration::from_secs(2)).await;
629 assert!(result.is_err());
630 assert_eq!(hits.load(Ordering::SeqCst), 0);
632 }
633
634 #[tokio::test]
635 async fn test_fetch_rejects_private_literal_without_network() {
636 let result = PageInfo::fetch_page_info("http://192.168.0.1/", Duration::from_secs(2)).await;
637 assert!(result.is_err());
638 }
639
640 #[tokio::test]
641 async fn test_fetch_rejects_localhost_hostname() {
642 let result = PageInfo::fetch_page_info("http://localhost:9/", Duration::from_secs(2)).await;
644 assert!(result.is_err());
645 }
646
647 #[tokio::test]
648 async fn test_fetch_follows_redirect_and_checks_every_hop() {
649 let (addr, hits) = spawn_mock_server(|_| {
652 "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.2:9/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
653 .to_string()
654 })
655 .await;
656 let url = format!("http://127.0.0.1:{}/", addr.port());
657 let result = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |ip| {
658 ip == IpAddr::V4(Ipv4Addr::LOCALHOST)
659 })
660 .await;
661 assert!(result.is_err());
662 assert_eq!(hits.load(Ordering::SeqCst), 1);
664 }
665
666 #[tokio::test]
667 async fn test_fetch_redirect_limit() {
668 let (addr, _hits) = spawn_mock_server(|addr| {
670 format!(
671 "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
672 addr.port()
673 )
674 })
675 .await;
676 let url = format!("http://127.0.0.1:{}/", addr.port());
677 let result = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(5), |_| true).await;
678 let err = result.err().expect("must fail after redirect limit");
679 assert!(err.to_string().contains("redirect"));
680 }
681
682 #[tokio::test]
683 async fn test_fetch_truncates_large_body() {
684 let body = format!(
687 "<html><head><title>Big</title></head><body>{}</body></html>",
688 "x".repeat(1_100_000)
689 );
690 let (addr, _hits) = spawn_mock_server(move |_| html_response(&body)).await;
691 let url = format!("http://127.0.0.1:{}/", addr.port());
692 let info = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(5), |_| true)
693 .await
694 .unwrap();
695 assert_eq!(info.title.as_deref(), Some("Big"));
696 }
697
698 #[tokio::test]
699 async fn test_fetch_rejects_non_html_content_type() {
700 let (addr, _hits) = spawn_mock_server(|_| {
701 "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 4\r\nConnection: close\r\n\r\nabcd"
702 .to_string()
703 })
704 .await;
705 let url = format!("http://127.0.0.1:{}/", addr.port());
706 let result = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |_| true).await;
707 let err = result.err().expect("must reject non-HTML content type");
708 assert!(err.to_string().contains("non-HTML"));
709 }
710
711 #[tokio::test]
712 async fn test_fetch_allows_missing_content_type() {
713 let body = "<html><head><title>NoCT</title></head><body></body></html>";
714 let (addr, _hits) = spawn_mock_server(|_| {
715 format!(
716 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
717 body.len(),
718 body
719 )
720 })
721 .await;
722 let url = format!("http://127.0.0.1:{}/", addr.port());
723 let info = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |_| true)
724 .await
725 .unwrap();
726 assert_eq!(info.title.as_deref(), Some("NoCT"));
727 }
728
729 #[tokio::test]
730 async fn test_shared_client_reused_across_fetches() {
731 let first: *const Client = &*OEMBED_CLIENT;
735 let second: *const Client = &*OEMBED_CLIENT;
736 assert!(
737 std::ptr::eq(first, second),
738 "OEMBED_CLIENT must be a single shared instance"
739 );
740
741 let (addr, hits) =
744 spawn_mock_server(|_| html_response("<html><head><title>Shared</title></head></html>"))
745 .await;
746 let url = format!("http://127.0.0.1:{}/", addr.port());
747 for _ in 0..2 {
748 let info = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |_| true)
749 .await
750 .unwrap();
751 assert_eq!(info.title.as_deref(), Some("Shared"));
752 }
753 assert_eq!(hits.load(Ordering::SeqCst), 2);
754 }
755
756 #[test]
757 fn test_giphy_media_url() {
758 let url = "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExaThmbzNhb3duNGM5NGVxemo4aHlnYTM1YXA4cGxmc2l2ejdjc2s4ZCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/CAxbo8KC2A0y4/giphy.gif";
759 let embed = PageInfo::giphy_embed(url);
760 assert!(embed.is_some());
761 let html = embed.unwrap();
762 assert!(html.contains("giphy-embed"));
763 assert!(html.contains(url));
764 }
765
766 #[test]
767 fn test_giphy_media_url_without_version() {
768 let url = "https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif";
769 let embed = PageInfo::giphy_embed(url);
770 assert!(embed.is_some());
771 let html = embed.unwrap();
772 assert!(html.contains("giphy-embed"));
773 assert!(html.contains(url));
774 }
775
776 #[test]
777 fn test_giphy_i_url() {
778 let url = "https://i.giphy.com/CAxbo8KC2A0y4.gif";
779 let embed = PageInfo::giphy_embed(url);
780 assert!(embed.is_some());
781 let html = embed.unwrap();
782 assert!(html.contains("giphy-embed"));
783 assert!(html.contains(url));
784 }
785
786 #[test]
787 fn test_giphy_page_url() {
788 let url = "https://giphy.com/gifs/cat-funny-CAxbo8KC2A0y4";
789 let embed = PageInfo::giphy_embed(url);
790 assert!(embed.is_some());
791 let html = embed.unwrap();
792 assert!(html.contains("giphy-embed"));
793 assert!(html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
794 }
795
796 #[test]
797 fn test_giphy_page_url_simple() {
798 let url = "https://giphy.com/gifs/CAxbo8KC2A0y4";
799 let embed = PageInfo::giphy_embed(url);
800 assert!(embed.is_some());
801 let html = embed.unwrap();
802 assert!(html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
803 }
804
805 #[test]
806 fn test_non_giphy_url() {
807 let url = "https://example.com/image.gif";
808 let embed = PageInfo::giphy_embed(url);
809 assert!(embed.is_none());
810 }
811
812 #[test]
813 fn test_youtube_not_giphy() {
814 let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
815 let embed = PageInfo::giphy_embed(url);
816 assert!(embed.is_none());
817 }
818
819 #[tokio::test]
820 async fn test_zero_timeout_returns_plain_link() {
821 let url = "https://example.com/some-page";
823 let result = PageInfo::new_from_url(url, 0).await;
824 assert!(result.is_ok());
825 let info = result.unwrap();
826 assert_eq!(info.url, url);
827 assert!(info.title.is_none()); assert!(info.embed_html.is_none());
829 assert!(info.html().contains("<a href="));
831 assert!(info.html().contains(url));
832 }
833
834 #[tokio::test]
835 async fn test_zero_timeout_still_allows_no_network_youtube_embed() {
836 let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
840 let result = PageInfo::new_from_url(url, 0).await;
841 assert!(result.is_ok());
842 let info = result.unwrap();
843 assert!(info.embed_html.is_some());
844 assert!(info.html().contains("youtube-embed"));
845 }
846
847 #[tokio::test]
848 async fn test_zero_timeout_still_allows_no_network_giphy_embed() {
849 let url = "https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif";
853 let result = PageInfo::new_from_url(url, 0).await;
854 assert!(result.is_ok());
855 let info = result.unwrap();
856 assert!(info.embed_html.is_some());
857 assert!(info.html().contains("giphy-embed"));
858
859 let network_info = PageInfo::new_from_url("https://example.com/some-page", 0)
862 .await
863 .unwrap();
864 assert!(network_info.embed_html.is_none());
865 assert!(network_info.html().contains("<a href="));
866 }
867
868 #[test]
869 fn test_local_embed_giphy_and_non_matching() {
870 let page = PageInfo::local_embed("https://giphy.com/gifs/cat-funny-CAxbo8KC2A0y4")
872 .expect("giphy page URL should produce a local embed");
873 let page_html = page.embed_html.expect("embed html present");
874 assert!(page_html.contains("giphy-embed"));
875 assert!(page_html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
876
877 let media_url = "https://media1.giphy.com/media/CAxbo8KC2A0y4/giphy.gif";
879 let media =
880 PageInfo::local_embed(media_url).expect("giphy media URL should produce a local embed");
881 let media_html = media.embed_html.expect("embed html present");
882 assert!(media_html.contains("giphy-embed"));
883 assert!(media_html.contains(media_url));
884
885 assert!(PageInfo::local_embed("https://example.com/article").is_none());
887 }
888
889 #[test]
890 fn test_estimated_size_empty() {
891 let info = PageInfo::default();
892 assert!(info.estimated_size() >= std::mem::size_of::<PageInfo>());
894 }
895
896 #[test]
897 fn test_estimated_size_with_fields() {
898 let info = PageInfo {
899 url: "https://example.com".to_string(),
900 title: Some("Test Title".to_string()),
901 description: Some("A longer description text".to_string()),
902 image: Some("https://example.com/image.png".to_string()),
903 embed_html: None,
904 };
905 let size = info.estimated_size();
906 let expected_min = std::mem::size_of::<PageInfo>()
908 + info.url.len()
909 + info.title.as_ref().unwrap().len()
910 + info.description.as_ref().unwrap().len()
911 + info.image.as_ref().unwrap().len();
912 assert_eq!(size, expected_min);
913 }
914
915 #[test]
916 fn test_clone() {
917 let info = PageInfo {
918 url: "https://example.com".to_string(),
919 title: Some("Test".to_string()),
920 description: None,
921 image: None,
922 embed_html: Some("<div></div>".to_string()),
923 };
924 let cloned = info.clone();
925 assert_eq!(cloned.url, info.url);
926 assert_eq!(cloned.title, info.title);
927 assert_eq!(cloned.embed_html, info.embed_html);
928 }
929
930 #[test]
931 fn test_gist_embed_basic() {
932 let url = "https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea";
933 let embed = PageInfo::gist_embed(url);
934 assert!(embed.is_some());
935 let html = embed.unwrap();
936 assert!(html.contains(
937 r#"<script src="https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea.js"></script>"#
938 ));
939 }
940
941 #[test]
942 fn test_gist_embed_with_fragment() {
943 let url = "https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea#file-order-ts";
944 let embed = PageInfo::gist_embed(url);
945 assert!(embed.is_some());
946 let html = embed.unwrap();
947 assert!(html.contains(
949 r#"<script src="https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea.js#file-order-ts"></script>"#
950 ));
951 }
952
953 #[test]
954 fn test_gist_embed_http() {
955 let url = "http://gist.github.com/user/abc123";
956 let embed = PageInfo::gist_embed(url);
957 assert!(embed.is_some());
958 }
959
960 #[test]
961 fn test_gist_non_gist_url() {
962 let url = "https://github.com/user/repo";
963 let embed = PageInfo::gist_embed(url);
964 assert!(embed.is_none());
965 }
966
967 #[test]
968 fn test_gist_not_youtube() {
969 let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
970 let embed = PageInfo::gist_embed(url);
971 assert!(embed.is_none());
972 }
973
974 #[tokio::test]
976 async fn test_bare_mp4_url_returns_video_embed() {
977 let url = "https://example.com/videos/demo.mp4";
978 let result = PageInfo::new_from_url(url, 500).await;
979 assert!(result.is_ok());
980 let info = result.unwrap();
981 assert!(info.embed_html.is_some());
982 let html = info.embed_html.unwrap();
983 assert!(html.contains("<video"));
984 assert!(html.contains(url));
985 }
986
987 #[tokio::test]
988 async fn test_bare_mp3_url_returns_audio_embed() {
989 let url = "https://example.com/audio/podcast.mp3";
990 let result = PageInfo::new_from_url(url, 500).await;
991 assert!(result.is_ok());
992 let info = result.unwrap();
993 assert!(info.embed_html.is_some());
994 let html = info.embed_html.unwrap();
995 assert!(html.contains("<audio"));
996 assert!(html.contains(url));
997 }
998
999 #[tokio::test]
1000 async fn test_bare_pdf_url_returns_pdf_embed() {
1001 let url = "https://example.com/docs/report.pdf";
1002 let result = PageInfo::new_from_url(url, 500).await;
1003 assert!(result.is_ok());
1004 let info = result.unwrap();
1005 assert!(info.embed_html.is_some());
1006 let html = info.embed_html.unwrap();
1007 assert!(html.contains("pdf-embed"));
1008 assert!(html.contains(r#"type="application/pdf""#));
1009 assert!(html.contains(url));
1010 }
1011
1012 #[tokio::test]
1013 async fn test_mp4_in_path_but_not_extension_goes_to_opengraph() {
1014 let url = "https://example.com/videos/mp4-format/info";
1017 let result = PageInfo::new_from_url(url, 500).await;
1018 assert!(result.is_ok());
1019 let info = result.unwrap();
1020 if let Some(html) = &info.embed_html {
1023 assert!(!html.contains("<video"));
1024 }
1025 }
1026
1027 #[tokio::test]
1028 async fn test_bare_m4v_url_returns_video_embed() {
1029 let url = "https://example.com/videos/clip.m4v";
1031 let result = PageInfo::new_from_url(url, 500).await;
1032 assert!(result.is_ok());
1033 let info = result.unwrap();
1034 assert!(info.embed_html.is_some());
1035 let html = info.embed_html.unwrap();
1036 assert!(html.contains("<video"));
1037 }
1038
1039 #[tokio::test]
1040 async fn test_bare_wav_url_returns_audio_embed() {
1041 let url = "https://example.com/sounds/effect.wav";
1042 let result = PageInfo::new_from_url(url, 500).await;
1043 assert!(result.is_ok());
1044 let info = result.unwrap();
1045 assert!(info.embed_html.is_some());
1046 let html = info.embed_html.unwrap();
1047 assert!(html.contains("<audio"));
1048 }
1049
1050 #[test]
1052 fn test_format_error_chain_single() {
1053 let err = std::io::Error::other("simple error");
1054 let chain = format_error_chain(&err);
1055 assert_eq!(chain, "simple error");
1056 }
1057
1058 #[test]
1059 fn test_format_error_chain_nested() {
1060 #[derive(Debug)]
1062 struct OuterError {
1063 source: Box<dyn Error + Send + Sync>,
1064 }
1065 impl std::fmt::Display for OuterError {
1066 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1067 write!(f, "outer error")
1068 }
1069 }
1070 impl Error for OuterError {
1071 fn source(&self) -> Option<&(dyn Error + 'static)> {
1072 Some(self.source.as_ref())
1073 }
1074 }
1075
1076 let inner = std::io::Error::other("inner error");
1077 let outer = OuterError {
1078 source: Box::new(inner),
1079 };
1080 let chain = format_error_chain(&outer);
1081 assert!(chain.contains("outer error"));
1082 assert!(chain.contains("inner error"));
1083 assert!(chain.contains(" -> "));
1084 }
1085
1086 #[test]
1088 fn test_supported_favicon_type_none() {
1089 assert!(is_supported_favicon_type(None));
1091 }
1092
1093 #[test]
1094 fn test_supported_favicon_type_png() {
1095 assert!(is_supported_favicon_type(Some("image/png")));
1096 }
1097
1098 #[test]
1099 fn test_supported_favicon_type_svg() {
1100 assert!(is_supported_favicon_type(Some("image/svg+xml")));
1101 }
1102
1103 #[test]
1104 fn test_supported_favicon_type_ico() {
1105 assert!(is_supported_favicon_type(Some("image/x-icon")));
1106 assert!(is_supported_favicon_type(Some("image/vnd.microsoft.icon")));
1107 }
1108
1109 #[test]
1110 fn test_unsupported_favicon_type() {
1111 assert!(!is_supported_favicon_type(Some("image/webp")));
1112 assert!(!is_supported_favicon_type(Some("text/html")));
1113 }
1114
1115 #[test]
1117 fn test_text_with_title() {
1118 let info = PageInfo {
1119 url: "https://example.com".to_string(),
1120 title: Some("My Title".to_string()),
1121 ..Default::default()
1122 };
1123 let text = info.text();
1124 assert_eq!(text, "My Title: https://example.com");
1125 }
1126
1127 #[test]
1128 fn test_text_without_title() {
1129 let info = PageInfo {
1130 url: "https://example.com".to_string(),
1131 ..Default::default()
1132 };
1133 let text = info.text();
1134 assert_eq!(text, "no title: https://example.com");
1135 }
1136
1137 #[test]
1139 fn test_html_with_embed() {
1140 let info = PageInfo {
1141 url: "https://example.com".to_string(),
1142 embed_html: Some("<div>embedded</div>".to_string()),
1143 ..Default::default()
1144 };
1145 let html = info.html();
1146 assert_eq!(html, "<div>embedded</div>");
1147 }
1148
1149 #[test]
1150 fn test_html_with_title_and_image() {
1151 let info = PageInfo {
1152 url: "https://example.com".to_string(),
1153 title: Some("My Title".to_string()),
1154 description: Some("My description".to_string()),
1155 image: Some("https://example.com/image.png".to_string()),
1156 embed_html: None,
1157 };
1158 let html = info.html();
1159 assert!(html.contains("mbr-social-link-box"));
1160 assert!(html.contains("My Title"));
1161 assert!(html.contains("My description"));
1162 assert!(html.contains(r#"<img src="https://example.com/image.png"/>"#));
1163 }
1164
1165 #[test]
1166 fn test_html_with_title_no_image() {
1167 let info = PageInfo {
1168 url: "https://example.com".to_string(),
1169 title: Some("Just Title".to_string()),
1170 description: None,
1171 image: None,
1172 embed_html: None,
1173 };
1174 let html = info.html();
1175 assert!(html.contains("mbr-social-link-box"));
1176 assert!(html.contains("Just Title"));
1177 assert!(!html.contains("<img"));
1178 }
1179
1180 #[test]
1181 fn test_html_no_title_plain_link() {
1182 let info = PageInfo {
1183 url: "https://example.com/page".to_string(),
1184 title: None,
1185 description: Some("ignored without title".to_string()),
1186 image: None,
1187 embed_html: None,
1188 };
1189 let html = info.html();
1190 assert!(html.contains(r#"<a href="https://example.com/page">"#));
1192 assert!(html.contains("https://example.com/page</a>"));
1193 assert!(!html.contains("mbr-social-link-box"));
1194 }
1195
1196 #[test]
1197 fn test_html_image_with_quotes_escaped() {
1198 let info = PageInfo {
1200 url: "https://example.com".to_string(),
1201 title: Some("Title".to_string()),
1202 image: Some("x' onerror='alert(1)".to_string()),
1203 ..Default::default()
1204 };
1205 let html = info.html();
1206 assert!(html.contains(r#"<img src="x' onerror='alert(1)"/>"#));
1208
1209 let info = PageInfo {
1211 url: "https://example.com".to_string(),
1212 title: Some("Title".to_string()),
1213 image: Some(r#"x" onerror="alert(1)"#.to_string()),
1214 ..Default::default()
1215 };
1216 let html = info.html();
1217 assert!(html.contains("""));
1218 assert!(!html.contains(r#"" onerror=""#));
1220 }
1221
1222 #[test]
1223 fn test_html_url_with_quote_escaped() {
1224 let evil_url = r#"https://example.com/"><script>alert(1)</script>"#;
1225
1226 let info = PageInfo {
1228 url: evil_url.to_string(),
1229 title: Some("Title".to_string()),
1230 ..Default::default()
1231 };
1232 let html = info.html();
1233 assert!(!html.contains(r#""><script>"#));
1234 assert!(!html.contains("<script>"));
1235
1236 let info = PageInfo {
1238 url: evil_url.to_string(),
1239 ..Default::default()
1240 };
1241 let html = info.html();
1242 assert!(!html.contains(r#""><script>"#));
1243 assert!(!html.contains("<script>"));
1245 assert!(html.contains("<script>"));
1246 }
1247
1248 #[test]
1249 fn test_html_favicon_quote_breakout_escaped() {
1250 let info = PageInfo {
1251 url: "https://example.com".to_string(),
1252 title: Some("Title".to_string()),
1253 image: Some(r#"x.png"><script>alert(1)</script>"#.to_string()),
1254 ..Default::default()
1255 };
1256 let html = info.html();
1257 assert!(!html.contains("<script>"));
1258 assert!(html.contains(""><script>"));
1259 }
1260
1261 #[test]
1262 fn test_extract_og_image_is_raw() {
1263 let doc = Html::parse_document(
1265 r#"<html><head>
1266 <meta property="og:title" content="T"/>
1267 <meta property="og:image" content="https://example.com/img?a=1&b=2"/>
1268 </head><body></body></html>"#,
1269 );
1270 let head = doc.select(&HEAD_SELECTOR).next().unwrap();
1271 let image = extract_content_raw(&head, &OG_IMAGE_SELECTOR).unwrap();
1272 assert_eq!(image, "https://example.com/img?a=1&b=2");
1274
1275 let info = PageInfo {
1276 url: "https://example.com".to_string(),
1277 title: Some("T".to_string()),
1278 image: Some(image),
1279 ..Default::default()
1280 };
1281 let html = info.html();
1282 assert!(html.contains(r#"<img src="https://example.com/img?a=1&b=2"/>"#));
1284 assert!(!html.contains("&amp;"));
1285 }
1286
1287 #[test]
1289 fn test_giphy_page_url_with_query() {
1290 let url = "https://giphy.com/gifs/cat-funny-CAxbo8KC2A0y4?utm_source=test";
1291 let embed = PageInfo::giphy_embed(url);
1292 assert!(embed.is_some());
1293 let html = embed.unwrap();
1294 assert!(html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
1295 }
1296
1297 #[test]
1298 fn test_giphy_webp_extension() {
1299 let url = "https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.webp";
1300 let embed = PageInfo::giphy_embed(url);
1301 assert!(embed.is_some());
1302 let html = embed.unwrap();
1303 assert!(html.contains(url));
1304 }
1305}