From b4eb8f4213725806f90ba64bbbffefda655302b7 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 10:50:28 -0700 Subject: [PATCH 1/4] fix(desktop): resolve YouTube previews through oEmbed Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src-tauri/src/commands/link_preview.rs | 192 +++++++++++++++++- 1 file changed, 189 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5ada1b3884..d593f6ba4c 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -8,7 +8,7 @@ use reqwest::{ header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT}, redirect::Policy, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use url::Url; #[path = "link_preview_rate_limit.rs"] @@ -17,6 +17,8 @@ mod rate_limit; use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024; +const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; +const YOUTUBE_OEMBED_ENDPOINT: &str = "https://www.youtube.com/oembed"; const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 4096; const MAX_IMAGE_PIXELS: u64 = 16_000_000; @@ -67,6 +69,10 @@ async fn fetch_link_preview_metadata_inner( let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; validate_public_https_url(&url).await?; + if is_youtube_video_url(&url) { + return fetch_youtube_oembed_metadata(&url).await; + } + for redirect_count in 0..=MAX_REDIRECTS { let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; @@ -134,6 +140,120 @@ async fn fetch_link_preview_metadata_inner( Ok(None) } +#[derive(Deserialize)] +struct YouTubeOEmbedResponse { + title: String, + author_name: Option, + provider_name: Option, + thumbnail_url: Option, +} + +fn is_youtube_video_url(url: &Url) -> bool { + let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { + return false; + }; + match host.as_str() { + "youtu.be" | "www.youtu.be" => url + .path_segments() + .and_then(|mut segments| segments.next()) + .is_some_and(|segment| !segment.is_empty()), + "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" => { + (url.path() == "/watch" + && url + .query_pairs() + .any(|(key, value)| key == "v" && !value.is_empty())) + || ["shorts", "live", "embed"].iter().any(|prefix| { + url.path_segments().is_some_and(|mut segments| { + segments.next() == Some(prefix) + && segments.next().is_some_and(|segment| !segment.is_empty()) + }) + }) + } + _ => false, + } +} + +async fn fetch_youtube_oembed_metadata( + video_url: &Url, +) -> Result, String> { + let mut oembed_url = Url::parse(YOUTUBE_OEMBED_ENDPOINT) + .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; + oembed_url + .query_pairs_mut() + .append_pair("format", "json") + .append_pair("url", video_url.as_str()); + + let response = send_pinned_request(&oembed_url, "application/json").await?; + if !response.status().is_success() || !is_json_response(&response) { + return Ok(None); + } + let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; + let response: YouTubeOEmbedResponse = match serde_json::from_slice(&body) { + Ok(response) => response, + Err(_) => return Ok(None), + }; + let Some((mut metadata, thumbnail_url)) = youtube_oembed_metadata(response) else { + return Ok(None); + }; + let image_result = match thumbnail_url { + Some(thumbnail_url) => Some( + tokio::time::timeout( + PREVIEW_FETCH_TIMEOUT, + fetch_sanitized_image(thumbnail_url, false), + ) + .await + .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + ), + None => None, + }; + apply_image_result(&mut metadata, image_result); + Ok(Some(metadata)) +} + +fn youtube_oembed_metadata( + response: YouTubeOEmbedResponse, +) -> Option<(LinkPreviewMetadata, Option)> { + let title = normalize_metadata_text(&response.title)?; + let thumbnail_url = response + .thumbnail_url + .as_deref() + .and_then(|thumbnail| Url::parse(thumbnail).ok()); + let metadata = LinkPreviewMetadata { + title, + site_name: response + .provider_name + .as_deref() + .and_then(normalize_metadata_text) + .or_else(|| Some("YouTube".to_string())), + description: response + .author_name + .as_deref() + .and_then(normalize_metadata_description), + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }; + Some((metadata, thumbnail_url)) +} + +fn is_json_response(response: &reqwest::Response) -> bool { + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false) +} + fn apply_image_result( metadata: &mut LinkPreviewMetadata, image_result: Option>, @@ -651,8 +771,9 @@ mod tests { use super::rate_limit::MAX_IMAGE_RETRY_AFTER; use super::{ apply_image_result, declares_animation, extract_favicon_url, extract_image_url, - extract_link_preview_metadata, is_html_response, read_bytes_prefix, retry_after_duration, - sanitize_image, ImageFetchError, LinkPreviewImageFetchState, LinkPreviewMetadata, + extract_link_preview_metadata, is_html_response, is_youtube_video_url, read_bytes_prefix, + retry_after_duration, sanitize_image, youtube_oembed_metadata, ImageFetchError, + LinkPreviewImageFetchState, LinkPreviewMetadata, YouTubeOEmbedResponse, MAX_METADATA_DESCRIPTION_CHARS, }; use axum::{body::Body, http::Response, routing::get, Router}; @@ -674,6 +795,71 @@ mod tests { .unwrap() } + #[test] + fn recognizes_supported_youtube_video_urls_only() { + for href in [ + "https://www.youtube.com/watch?v=hLFs9JtMaRg", + "https://m.youtube.com/watch?v=hLFs9JtMaRg&feature=share", + "https://music.youtube.com/watch?v=hLFs9JtMaRg", + "https://youtu.be/hLFs9JtMaRg?t=10", + "https://www.youtube.com/shorts/hLFs9JtMaRg", + "https://www.youtube.com/live/hLFs9JtMaRg", + "https://www.youtube.com/embed/hLFs9JtMaRg", + ] { + assert!(is_youtube_video_url(&Url::parse(href).unwrap()), "{href}"); + } + for href in [ + "https://www.youtube.com/", + "https://www.youtube.com/watch", + "https://www.youtube.com/watch?v=", + "https://www.youtube.com/@buzz", + "https://youtube.com.evil.example/watch?v=hLFs9JtMaRg", + "https://notyoutube.com/watch?v=hLFs9JtMaRg", + ] { + assert!(!is_youtube_video_url(&Url::parse(href).unwrap()), "{href}"); + } + } + + #[test] + fn converts_youtube_oembed_response_to_bounded_preview_metadata() { + let (metadata, thumbnail_url) = youtube_oembed_metadata(YouTubeOEmbedResponse { + title: " Video title ".to_string(), + author_name: Some("Buzz Creator".to_string()), + provider_name: Some("YouTube".to_string()), + thumbnail_url: Some("https://i.ytimg.com/vi/example/hqdefault.jpg".to_string()), + }) + .unwrap(); + assert_eq!(metadata.title, "Video title"); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(metadata.description.as_deref(), Some("Buzz Creator")); + assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); + assert_eq!( + thumbnail_url.unwrap().as_str(), + "https://i.ytimg.com/vi/example/hqdefault.jpg" + ); + } + + #[test] + fn rejects_titleless_youtube_oembed_response_and_ignores_invalid_thumbnail() { + assert!(youtube_oembed_metadata(YouTubeOEmbedResponse { + title: " ".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: None, + }) + .is_none()); + + let (metadata, thumbnail_url) = youtube_oembed_metadata(YouTubeOEmbedResponse { + title: "Video title".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: Some("not a URL".to_string()), + }) + .unwrap(); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(thumbnail_url, None); + } + #[test] fn metadata_prefers_open_graph_and_reads_site_name() { let html = r#" From 9fc29faa2923cf9103b100494a9dba06b686f05b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 11:07:20 -0700 Subject: [PATCH 2/4] fix(desktop): canonicalize YouTube embed previews Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src-tauri/src/commands/link_preview.rs | 164 +++++++++++++++--- 1 file changed, 144 insertions(+), 20 deletions(-) diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index d593f6ba4c..cf381650de 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -176,23 +176,9 @@ fn is_youtube_video_url(url: &Url) -> bool { async fn fetch_youtube_oembed_metadata( video_url: &Url, ) -> Result, String> { - let mut oembed_url = Url::parse(YOUTUBE_OEMBED_ENDPOINT) - .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; - oembed_url - .query_pairs_mut() - .append_pair("format", "json") - .append_pair("url", video_url.as_str()); - + let oembed_url = youtube_oembed_url(video_url)?; let response = send_pinned_request(&oembed_url, "application/json").await?; - if !response.status().is_success() || !is_json_response(&response) { - return Ok(None); - } - let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; - let response: YouTubeOEmbedResponse = match serde_json::from_slice(&body) { - Ok(response) => response, - Err(_) => return Ok(None), - }; - let Some((mut metadata, thumbnail_url)) = youtube_oembed_metadata(response) else { + let Some((mut metadata, thumbnail_url)) = parse_youtube_oembed_response(response).await? else { return Ok(None); }; let image_result = match thumbnail_url { @@ -210,6 +196,49 @@ async fn fetch_youtube_oembed_metadata( Ok(Some(metadata)) } +fn youtube_oembed_url(video_url: &Url) -> Result { + let mut canonical_video_url = video_url.clone(); + if matches!( + video_url.host_str(), + Some("youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com") + ) { + let mut segments = video_url.path_segments(); + if segments.as_mut().and_then(|segments| segments.next()) == Some("embed") { + let video_id = segments + .and_then(|mut segments| segments.next()) + .ok_or_else(|| "YouTube embed URL has no video ID".to_string())?; + canonical_video_url.set_path("/watch"); + canonical_video_url.set_query(None); + canonical_video_url + .query_pairs_mut() + .append_pair("v", video_id); + canonical_video_url.set_fragment(None); + } + } + + let mut oembed_url = Url::parse(YOUTUBE_OEMBED_ENDPOINT) + .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; + oembed_url + .query_pairs_mut() + .append_pair("format", "json") + .append_pair("url", canonical_video_url.as_str()); + Ok(oembed_url) +} + +async fn parse_youtube_oembed_response( + response: reqwest::Response, +) -> Result)>, String> { + if !response.status().is_success() || !is_json_response(&response) { + return Ok(None); + } + let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; + let response: YouTubeOEmbedResponse = match serde_json::from_slice(&body) { + Ok(response) => response, + Err(_) => return Ok(None), + }; + Ok(youtube_oembed_metadata(response)) +} + fn youtube_oembed_metadata( response: YouTubeOEmbedResponse, ) -> Option<(LinkPreviewMetadata, Option)> { @@ -771,10 +800,11 @@ mod tests { use super::rate_limit::MAX_IMAGE_RETRY_AFTER; use super::{ apply_image_result, declares_animation, extract_favicon_url, extract_image_url, - extract_link_preview_metadata, is_html_response, is_youtube_video_url, read_bytes_prefix, - retry_after_duration, sanitize_image, youtube_oembed_metadata, ImageFetchError, - LinkPreviewImageFetchState, LinkPreviewMetadata, YouTubeOEmbedResponse, - MAX_METADATA_DESCRIPTION_CHARS, + extract_link_preview_metadata, is_html_response, is_youtube_video_url, + parse_youtube_oembed_response, read_bytes_prefix, retry_after_duration, sanitize_image, + youtube_oembed_metadata, youtube_oembed_url, ImageFetchError, LinkPreviewImageFetchState, + LinkPreviewMetadata, YouTubeOEmbedResponse, MAX_METADATA_DESCRIPTION_CHARS, + MAX_OEMBED_FETCH_BYTES, }; use axum::{body::Body, http::Response, routing::get, Router}; use base64::Engine as _; @@ -820,6 +850,100 @@ mod tests { } } + #[test] + fn canonicalizes_youtube_embed_url_for_oembed() { + let oembed_url = youtube_oembed_url( + &Url::parse("https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player").unwrap(), + ) + .unwrap(); + let params = oembed_url + .query_pairs() + .collect::>(); + assert_eq!( + params.get("format").map(|value| value.as_ref()), + Some("json") + ); + assert_eq!( + params.get("url").map(|value| value.as_ref()), + Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + ); + } + + #[tokio::test] + async fn youtube_oembed_response_requires_successful_bounded_json() { + let valid_json = + r#"{"title":"Video title","author_name":"Creator","provider_name":"YouTube"}"#; + let response = test_response( + Router::new().route( + "/valid", + get(move || async move { + Response::builder() + .header("content-type", "application/json; charset=UTF-8") + .body(Body::from(valid_json)) + .unwrap() + }), + ), + "/valid", + ) + .await; + let (metadata, _) = parse_youtube_oembed_response(response) + .await + .unwrap() + .unwrap(); + assert_eq!(metadata.title, "Video title"); + + for response in [ + test_response( + Router::new().route( + "/not-found", + get(|| async { + Response::builder() + .status(404) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap() + }), + ), + "/not-found", + ) + .await, + test_response( + Router::new().route( + "/html", + get(|| async { + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Not JSON")) + .unwrap() + }), + ), + "/html", + ) + .await, + ] { + assert_eq!(parse_youtube_oembed_response(response).await.unwrap(), None); + } + + let oversized = vec![b' '; MAX_OEMBED_FETCH_BYTES + 1]; + let response = test_response( + Router::new().route( + "/oversized", + get(move || { + let oversized = oversized.clone(); + async move { + Response::builder() + .header("content-type", "application/json") + .body(Body::from(oversized)) + .unwrap() + } + }), + ), + "/oversized", + ) + .await; + assert!(parse_youtube_oembed_response(response).await.is_err()); + } + #[test] fn converts_youtube_oembed_response_to_bounded_preview_metadata() { let (metadata, thumbnail_url) = youtube_oembed_metadata(YouTubeOEmbedResponse { From ff36db5fcde1bf8e4e8cffd3dbc19355ff6c14d3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 14:20:41 -0700 Subject: [PATCH 3/4] fix(desktop): decode YouTube embed video IDs Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/src/commands/link_preview.rs | 72 ++++++++++++++----- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index a69f425024..b8253161a8 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1108,6 +1108,7 @@ dependencies = [ "objc2-foundation", "objc2-user-notifications", "opus", + "percent-encoding", "plist", "png 0.18.1", "portable-pty", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9b2de6a575..72d8e2d10a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -98,6 +98,7 @@ nostr = { version = "0.44", features = ["nip44", "nip49"] } # transitive dependency; pinned here for direct use). getrandom = "0.2" zeroize = "1" +percent-encoding = "2" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } url = "2" diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index cf381650de..65a2a7368c 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -2,6 +2,7 @@ use std::{io::Cursor, net::IpAddr, time::Duration}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use image::ImageDecoder; +use percent_encoding::percent_decode_str; use futures_util::StreamExt; use reqwest::{ @@ -204,14 +205,22 @@ fn youtube_oembed_url(video_url: &Url) -> Result { ) { let mut segments = video_url.path_segments(); if segments.as_mut().and_then(|segments| segments.next()) == Some("embed") { - let video_id = segments + let encoded_video_id = segments .and_then(|mut segments| segments.next()) .ok_or_else(|| "YouTube embed URL has no video ID".to_string())?; + let video_id = percent_decode_str(encoded_video_id) + .decode_utf8() + .map_err(|_| "YouTube embed URL has an invalid video ID".to_string())?; + if !video_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) { + return Err("YouTube embed URL has an invalid video ID".to_string()); + } canonical_video_url.set_path("/watch"); canonical_video_url.set_query(None); canonical_video_url .query_pairs_mut() - .append_pair("v", video_id); + .append_pair("v", &video_id); canonical_video_url.set_fragment(None); } } @@ -852,21 +861,50 @@ mod tests { #[test] fn canonicalizes_youtube_embed_url_for_oembed() { - let oembed_url = youtube_oembed_url( - &Url::parse("https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player").unwrap(), - ) - .unwrap(); - let params = oembed_url - .query_pairs() - .collect::>(); - assert_eq!( - params.get("format").map(|value| value.as_ref()), - Some("json") - ); - assert_eq!( - params.get("url").map(|value| value.as_ref()), - Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ") - ); + for (video_url, expected_video_id) in [ + ( + "https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player", + "dQw4w9WgXcQ", + ), + ("https://www.youtube.com/embed/%64Qw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/embed/dQw4w9WgX%63Q", "dQw4w9WgXcQ"), + ] { + let oembed_url = youtube_oembed_url(&Url::parse(video_url).unwrap()).unwrap(); + let params = oembed_url + .query_pairs() + .collect::>(); + assert_eq!( + params.get("format").map(|value| value.as_ref()), + Some("json") + ); + let provider_video_url = + Url::parse(params.get("url").expect("oEmbed URL parameter")).unwrap(); + assert_eq!(provider_video_url.path(), "/watch"); + assert_eq!( + provider_video_url + .query_pairs() + .find(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()), + Some(expected_video_id.to_string()) + ); + } + } + + #[test] + fn rejects_invalid_encoded_youtube_embed_video_ids() { + for href in [ + "https://www.youtube.com/embed/video%2Fid", + "https://www.youtube.com/embed/video%5Cid", + "https://www.youtube.com/embed/video%00id", + "https://www.youtube.com/embed/video%25id", + "https://www.youtube.com/embed/video%252Fid", + "https://www.youtube.com/embed/video%FFid", + ] { + assert!( + youtube_oembed_url(&Url::parse(href).unwrap()).is_err(), + "{href}" + ); + } } #[tokio::test] From 53ebc464ee0d2bd74089e70a79a065e264a5c024 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 15:40:37 -0700 Subject: [PATCH 4/4] refactor(desktop): isolate YouTube link previews Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src-tauri/src/commands/link_preview.rs | 361 +----------------- .../src/commands/link_preview_youtube.rs | 361 ++++++++++++++++++ 2 files changed, 370 insertions(+), 352 deletions(-) create mode 100644 desktop/src-tauri/src/commands/link_preview_youtube.rs diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 65a2a7368c..732c750a13 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -1,25 +1,23 @@ use std::{io::Cursor, net::IpAddr, time::Duration}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use image::ImageDecoder; -use percent_encoding::percent_decode_str; - use futures_util::StreamExt; +use image::ImageDecoder; use reqwest::{ header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT}, redirect::Policy, }; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use url::Url; #[path = "link_preview_rate_limit.rs"] mod rate_limit; +#[path = "link_preview_youtube.rs"] +mod youtube; use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024; -const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; -const YOUTUBE_OEMBED_ENDPOINT: &str = "https://www.youtube.com/oembed"; const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 4096; const MAX_IMAGE_PIXELS: u64 = 16_000_000; @@ -70,8 +68,8 @@ async fn fetch_link_preview_metadata_inner( let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; validate_public_https_url(&url).await?; - if is_youtube_video_url(&url) { - return fetch_youtube_oembed_metadata(&url).await; + if youtube::is_video_url(&url) { + return youtube::fetch_oembed_metadata(&url).await; } for redirect_count in 0..=MAX_REDIRECTS { @@ -141,157 +139,6 @@ async fn fetch_link_preview_metadata_inner( Ok(None) } -#[derive(Deserialize)] -struct YouTubeOEmbedResponse { - title: String, - author_name: Option, - provider_name: Option, - thumbnail_url: Option, -} - -fn is_youtube_video_url(url: &Url) -> bool { - let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { - return false; - }; - match host.as_str() { - "youtu.be" | "www.youtu.be" => url - .path_segments() - .and_then(|mut segments| segments.next()) - .is_some_and(|segment| !segment.is_empty()), - "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" => { - (url.path() == "/watch" - && url - .query_pairs() - .any(|(key, value)| key == "v" && !value.is_empty())) - || ["shorts", "live", "embed"].iter().any(|prefix| { - url.path_segments().is_some_and(|mut segments| { - segments.next() == Some(prefix) - && segments.next().is_some_and(|segment| !segment.is_empty()) - }) - }) - } - _ => false, - } -} - -async fn fetch_youtube_oembed_metadata( - video_url: &Url, -) -> Result, String> { - let oembed_url = youtube_oembed_url(video_url)?; - let response = send_pinned_request(&oembed_url, "application/json").await?; - let Some((mut metadata, thumbnail_url)) = parse_youtube_oembed_response(response).await? else { - return Ok(None); - }; - let image_result = match thumbnail_url { - Some(thumbnail_url) => Some( - tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(thumbnail_url, false), - ) - .await - .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), - ), - None => None, - }; - apply_image_result(&mut metadata, image_result); - Ok(Some(metadata)) -} - -fn youtube_oembed_url(video_url: &Url) -> Result { - let mut canonical_video_url = video_url.clone(); - if matches!( - video_url.host_str(), - Some("youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com") - ) { - let mut segments = video_url.path_segments(); - if segments.as_mut().and_then(|segments| segments.next()) == Some("embed") { - let encoded_video_id = segments - .and_then(|mut segments| segments.next()) - .ok_or_else(|| "YouTube embed URL has no video ID".to_string())?; - let video_id = percent_decode_str(encoded_video_id) - .decode_utf8() - .map_err(|_| "YouTube embed URL has an invalid video ID".to_string())?; - if !video_id.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '-' | '_') - }) { - return Err("YouTube embed URL has an invalid video ID".to_string()); - } - canonical_video_url.set_path("/watch"); - canonical_video_url.set_query(None); - canonical_video_url - .query_pairs_mut() - .append_pair("v", &video_id); - canonical_video_url.set_fragment(None); - } - } - - let mut oembed_url = Url::parse(YOUTUBE_OEMBED_ENDPOINT) - .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; - oembed_url - .query_pairs_mut() - .append_pair("format", "json") - .append_pair("url", canonical_video_url.as_str()); - Ok(oembed_url) -} - -async fn parse_youtube_oembed_response( - response: reqwest::Response, -) -> Result)>, String> { - if !response.status().is_success() || !is_json_response(&response) { - return Ok(None); - } - let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; - let response: YouTubeOEmbedResponse = match serde_json::from_slice(&body) { - Ok(response) => response, - Err(_) => return Ok(None), - }; - Ok(youtube_oembed_metadata(response)) -} - -fn youtube_oembed_metadata( - response: YouTubeOEmbedResponse, -) -> Option<(LinkPreviewMetadata, Option)> { - let title = normalize_metadata_text(&response.title)?; - let thumbnail_url = response - .thumbnail_url - .as_deref() - .and_then(|thumbnail| Url::parse(thumbnail).ok()); - let metadata = LinkPreviewMetadata { - title, - site_name: response - .provider_name - .as_deref() - .and_then(normalize_metadata_text) - .or_else(|| Some("YouTube".to_string())), - description: response - .author_name - .as_deref() - .and_then(normalize_metadata_description), - image_data_url: None, - image_domain: None, - image_fetch_state: LinkPreviewImageFetchState::None, - image_retry_after_ms: None, - favicon_data_url: None, - }; - Some((metadata, thumbnail_url)) -} - -fn is_json_response(response: &reqwest::Response) -> bool { - response - .headers() - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| { - value - .split(';') - .next() - .unwrap_or_default() - .trim() - .eq_ignore_ascii_case("application/json") - }) - .unwrap_or(false) -} - fn apply_image_result( metadata: &mut LinkPreviewMetadata, image_result: Option>, @@ -809,11 +656,9 @@ mod tests { use super::rate_limit::MAX_IMAGE_RETRY_AFTER; use super::{ apply_image_result, declares_animation, extract_favicon_url, extract_image_url, - extract_link_preview_metadata, is_html_response, is_youtube_video_url, - parse_youtube_oembed_response, read_bytes_prefix, retry_after_duration, sanitize_image, - youtube_oembed_metadata, youtube_oembed_url, ImageFetchError, LinkPreviewImageFetchState, - LinkPreviewMetadata, YouTubeOEmbedResponse, MAX_METADATA_DESCRIPTION_CHARS, - MAX_OEMBED_FETCH_BYTES, + extract_link_preview_metadata, is_html_response, read_bytes_prefix, retry_after_duration, + sanitize_image, ImageFetchError, LinkPreviewImageFetchState, LinkPreviewMetadata, + MAX_METADATA_DESCRIPTION_CHARS, }; use axum::{body::Body, http::Response, routing::get, Router}; use base64::Engine as _; @@ -834,194 +679,6 @@ mod tests { .unwrap() } - #[test] - fn recognizes_supported_youtube_video_urls_only() { - for href in [ - "https://www.youtube.com/watch?v=hLFs9JtMaRg", - "https://m.youtube.com/watch?v=hLFs9JtMaRg&feature=share", - "https://music.youtube.com/watch?v=hLFs9JtMaRg", - "https://youtu.be/hLFs9JtMaRg?t=10", - "https://www.youtube.com/shorts/hLFs9JtMaRg", - "https://www.youtube.com/live/hLFs9JtMaRg", - "https://www.youtube.com/embed/hLFs9JtMaRg", - ] { - assert!(is_youtube_video_url(&Url::parse(href).unwrap()), "{href}"); - } - for href in [ - "https://www.youtube.com/", - "https://www.youtube.com/watch", - "https://www.youtube.com/watch?v=", - "https://www.youtube.com/@buzz", - "https://youtube.com.evil.example/watch?v=hLFs9JtMaRg", - "https://notyoutube.com/watch?v=hLFs9JtMaRg", - ] { - assert!(!is_youtube_video_url(&Url::parse(href).unwrap()), "{href}"); - } - } - - #[test] - fn canonicalizes_youtube_embed_url_for_oembed() { - for (video_url, expected_video_id) in [ - ( - "https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player", - "dQw4w9WgXcQ", - ), - ("https://www.youtube.com/embed/%64Qw4w9WgXcQ", "dQw4w9WgXcQ"), - ("https://www.youtube.com/embed/dQw4w9WgX%63Q", "dQw4w9WgXcQ"), - ] { - let oembed_url = youtube_oembed_url(&Url::parse(video_url).unwrap()).unwrap(); - let params = oembed_url - .query_pairs() - .collect::>(); - assert_eq!( - params.get("format").map(|value| value.as_ref()), - Some("json") - ); - let provider_video_url = - Url::parse(params.get("url").expect("oEmbed URL parameter")).unwrap(); - assert_eq!(provider_video_url.path(), "/watch"); - assert_eq!( - provider_video_url - .query_pairs() - .find(|(key, _)| key == "v") - .map(|(_, value)| value.into_owned()), - Some(expected_video_id.to_string()) - ); - } - } - - #[test] - fn rejects_invalid_encoded_youtube_embed_video_ids() { - for href in [ - "https://www.youtube.com/embed/video%2Fid", - "https://www.youtube.com/embed/video%5Cid", - "https://www.youtube.com/embed/video%00id", - "https://www.youtube.com/embed/video%25id", - "https://www.youtube.com/embed/video%252Fid", - "https://www.youtube.com/embed/video%FFid", - ] { - assert!( - youtube_oembed_url(&Url::parse(href).unwrap()).is_err(), - "{href}" - ); - } - } - - #[tokio::test] - async fn youtube_oembed_response_requires_successful_bounded_json() { - let valid_json = - r#"{"title":"Video title","author_name":"Creator","provider_name":"YouTube"}"#; - let response = test_response( - Router::new().route( - "/valid", - get(move || async move { - Response::builder() - .header("content-type", "application/json; charset=UTF-8") - .body(Body::from(valid_json)) - .unwrap() - }), - ), - "/valid", - ) - .await; - let (metadata, _) = parse_youtube_oembed_response(response) - .await - .unwrap() - .unwrap(); - assert_eq!(metadata.title, "Video title"); - - for response in [ - test_response( - Router::new().route( - "/not-found", - get(|| async { - Response::builder() - .status(404) - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap() - }), - ), - "/not-found", - ) - .await, - test_response( - Router::new().route( - "/html", - get(|| async { - Response::builder() - .header("content-type", "text/html") - .body(Body::from("Not JSON")) - .unwrap() - }), - ), - "/html", - ) - .await, - ] { - assert_eq!(parse_youtube_oembed_response(response).await.unwrap(), None); - } - - let oversized = vec![b' '; MAX_OEMBED_FETCH_BYTES + 1]; - let response = test_response( - Router::new().route( - "/oversized", - get(move || { - let oversized = oversized.clone(); - async move { - Response::builder() - .header("content-type", "application/json") - .body(Body::from(oversized)) - .unwrap() - } - }), - ), - "/oversized", - ) - .await; - assert!(parse_youtube_oembed_response(response).await.is_err()); - } - - #[test] - fn converts_youtube_oembed_response_to_bounded_preview_metadata() { - let (metadata, thumbnail_url) = youtube_oembed_metadata(YouTubeOEmbedResponse { - title: " Video title ".to_string(), - author_name: Some("Buzz Creator".to_string()), - provider_name: Some("YouTube".to_string()), - thumbnail_url: Some("https://i.ytimg.com/vi/example/hqdefault.jpg".to_string()), - }) - .unwrap(); - assert_eq!(metadata.title, "Video title"); - assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); - assert_eq!(metadata.description.as_deref(), Some("Buzz Creator")); - assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); - assert_eq!( - thumbnail_url.unwrap().as_str(), - "https://i.ytimg.com/vi/example/hqdefault.jpg" - ); - } - - #[test] - fn rejects_titleless_youtube_oembed_response_and_ignores_invalid_thumbnail() { - assert!(youtube_oembed_metadata(YouTubeOEmbedResponse { - title: " ".to_string(), - author_name: None, - provider_name: None, - thumbnail_url: None, - }) - .is_none()); - - let (metadata, thumbnail_url) = youtube_oembed_metadata(YouTubeOEmbedResponse { - title: "Video title".to_string(), - author_name: None, - provider_name: None, - thumbnail_url: Some("not a URL".to_string()), - }) - .unwrap(); - assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); - assert_eq!(thumbnail_url, None); - } - #[test] fn metadata_prefers_open_graph_and_reads_site_name() { let html = r#" diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs new file mode 100644 index 0000000000..722c481b5e --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -0,0 +1,361 @@ +use percent_encoding::percent_decode_str; +use reqwest::header::CONTENT_TYPE; +use serde::Deserialize; +use url::Url; + +use super::{ + apply_image_result, fetch_sanitized_image, normalize_metadata_description, + normalize_metadata_text, read_limited_bytes, send_pinned_request, ImageFetchError, + LinkPreviewImageFetchState, LinkPreviewMetadata, PREVIEW_FETCH_TIMEOUT, +}; + +const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; +const OEMBED_ENDPOINT: &str = "https://www.youtube.com/oembed"; + +#[derive(Deserialize)] +struct OEmbedResponse { + title: String, + author_name: Option, + provider_name: Option, + thumbnail_url: Option, +} + +pub(super) fn is_video_url(url: &Url) -> bool { + let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { + return false; + }; + match host.as_str() { + "youtu.be" | "www.youtu.be" => url + .path_segments() + .and_then(|mut segments| segments.next()) + .is_some_and(|segment| !segment.is_empty()), + "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" => { + (url.path() == "/watch" + && url + .query_pairs() + .any(|(key, value)| key == "v" && !value.is_empty())) + || ["shorts", "live", "embed"].iter().any(|prefix| { + url.path_segments().is_some_and(|mut segments| { + segments.next() == Some(prefix) + && segments.next().is_some_and(|segment| !segment.is_empty()) + }) + }) + } + _ => false, + } +} + +pub(super) async fn fetch_oembed_metadata( + video_url: &Url, +) -> Result, String> { + let oembed_url = oembed_url(video_url)?; + let response = send_pinned_request(&oembed_url, "application/json").await?; + let Some((mut metadata, thumbnail_url)) = parse_oembed_response(response).await? else { + return Ok(None); + }; + let image_result = match thumbnail_url { + Some(thumbnail_url) => Some( + tokio::time::timeout( + PREVIEW_FETCH_TIMEOUT, + fetch_sanitized_image(thumbnail_url, false), + ) + .await + .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + ), + None => None, + }; + apply_image_result(&mut metadata, image_result); + Ok(Some(metadata)) +} + +fn oembed_url(video_url: &Url) -> Result { + let mut canonical_video_url = video_url.clone(); + if matches!( + video_url.host_str(), + Some("youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com") + ) { + let mut segments = video_url.path_segments(); + if segments.as_mut().and_then(|segments| segments.next()) == Some("embed") { + let encoded_video_id = segments + .and_then(|mut segments| segments.next()) + .ok_or_else(|| "YouTube embed URL has no video ID".to_string())?; + let video_id = percent_decode_str(encoded_video_id) + .decode_utf8() + .map_err(|_| "YouTube embed URL has an invalid video ID".to_string())?; + if !video_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) { + return Err("YouTube embed URL has an invalid video ID".to_string()); + } + canonical_video_url.set_path("/watch"); + canonical_video_url.set_query(None); + canonical_video_url + .query_pairs_mut() + .append_pair("v", &video_id); + canonical_video_url.set_fragment(None); + } + } + + let mut oembed_url = Url::parse(OEMBED_ENDPOINT) + .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; + oembed_url + .query_pairs_mut() + .append_pair("format", "json") + .append_pair("url", canonical_video_url.as_str()); + Ok(oembed_url) +} + +async fn parse_oembed_response( + response: reqwest::Response, +) -> Result)>, String> { + if !response.status().is_success() || !is_json_response(&response) { + return Ok(None); + } + let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; + let response: OEmbedResponse = match serde_json::from_slice(&body) { + Ok(response) => response, + Err(_) => return Ok(None), + }; + Ok(oembed_metadata(response)) +} + +fn oembed_metadata(response: OEmbedResponse) -> Option<(LinkPreviewMetadata, Option)> { + let title = normalize_metadata_text(&response.title)?; + let thumbnail_url = response + .thumbnail_url + .as_deref() + .and_then(|thumbnail| Url::parse(thumbnail).ok()); + let metadata = LinkPreviewMetadata { + title, + site_name: response + .provider_name + .as_deref() + .and_then(normalize_metadata_text) + .or_else(|| Some("YouTube".to_string())), + description: response + .author_name + .as_deref() + .and_then(normalize_metadata_description), + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }; + Some((metadata, thumbnail_url)) +} + +fn is_json_response(response: &reqwest::Response) -> bool { + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Response, routing::get, Router}; + + async fn test_response(router: Router, path: &str) -> reqwest::Response { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() + } + + #[test] + fn recognizes_supported_video_urls_only() { + for href in [ + "https://www.youtube.com/watch?v=hLFs9JtMaRg", + "https://m.youtube.com/watch?v=hLFs9JtMaRg&feature=share", + "https://music.youtube.com/watch?v=hLFs9JtMaRg", + "https://youtu.be/hLFs9JtMaRg?t=10", + "https://www.youtube.com/shorts/hLFs9JtMaRg", + "https://www.youtube.com/live/hLFs9JtMaRg", + "https://www.youtube.com/embed/hLFs9JtMaRg", + ] { + assert!(is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + for href in [ + "https://www.youtube.com/", + "https://www.youtube.com/watch", + "https://www.youtube.com/watch?v=", + "https://www.youtube.com/@buzz", + "https://youtube.com.evil.example/watch?v=hLFs9JtMaRg", + "https://notyoutube.com/watch?v=hLFs9JtMaRg", + ] { + assert!(!is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + } + + #[test] + fn canonicalizes_embed_url_for_oembed() { + for (video_url, expected_video_id) in [ + ( + "https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player", + "dQw4w9WgXcQ", + ), + ("https://www.youtube.com/embed/%64Qw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/embed/dQw4w9WgX%63Q", "dQw4w9WgXcQ"), + ] { + let oembed_url = oembed_url(&Url::parse(video_url).unwrap()).unwrap(); + let params = oembed_url + .query_pairs() + .collect::>(); + assert_eq!( + params.get("format").map(|value| value.as_ref()), + Some("json") + ); + let provider_video_url = + Url::parse(params.get("url").expect("oEmbed URL parameter")).unwrap(); + assert_eq!(provider_video_url.path(), "/watch"); + assert_eq!( + provider_video_url + .query_pairs() + .find(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()), + Some(expected_video_id.to_string()) + ); + } + } + + #[test] + fn rejects_invalid_encoded_embed_video_ids() { + for href in [ + "https://www.youtube.com/embed/video%2Fid", + "https://www.youtube.com/embed/video%5Cid", + "https://www.youtube.com/embed/video%00id", + "https://www.youtube.com/embed/video%25id", + "https://www.youtube.com/embed/video%252Fid", + "https://www.youtube.com/embed/video%FFid", + ] { + assert!(oembed_url(&Url::parse(href).unwrap()).is_err(), "{href}"); + } + } + + #[tokio::test] + async fn response_requires_successful_bounded_json() { + let valid_json = + r#"{"title":"Video title","author_name":"Creator","provider_name":"YouTube"}"#; + let response = test_response( + Router::new().route( + "/valid", + get(move || async move { + Response::builder() + .header("content-type", "application/json; charset=UTF-8") + .body(Body::from(valid_json)) + .unwrap() + }), + ), + "/valid", + ) + .await; + let (metadata, _) = parse_oembed_response(response).await.unwrap().unwrap(); + assert_eq!(metadata.title, "Video title"); + + for response in [ + test_response( + Router::new().route( + "/not-found", + get(|| async { + Response::builder() + .status(404) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap() + }), + ), + "/not-found", + ) + .await, + test_response( + Router::new().route( + "/html", + get(|| async { + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Not JSON")) + .unwrap() + }), + ), + "/html", + ) + .await, + ] { + assert_eq!(parse_oembed_response(response).await.unwrap(), None); + } + + let oversized = vec![b' '; MAX_OEMBED_FETCH_BYTES + 1]; + let response = test_response( + Router::new().route( + "/oversized", + get(move || { + let oversized = oversized.clone(); + async move { + Response::builder() + .header("content-type", "application/json") + .body(Body::from(oversized)) + .unwrap() + } + }), + ), + "/oversized", + ) + .await; + assert!(parse_oembed_response(response).await.is_err()); + } + + #[test] + fn converts_response_to_bounded_preview_metadata() { + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: " Video title ".to_string(), + author_name: Some("Buzz Creator".to_string()), + provider_name: Some("YouTube".to_string()), + thumbnail_url: Some("https://i.ytimg.com/vi/example/hqdefault.jpg".to_string()), + }) + .unwrap(); + assert_eq!(metadata.title, "Video title"); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(metadata.description.as_deref(), Some("Buzz Creator")); + assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); + assert_eq!( + thumbnail_url.unwrap().as_str(), + "https://i.ytimg.com/vi/example/hqdefault.jpg" + ); + } + + #[test] + fn rejects_titleless_response_and_ignores_invalid_thumbnail() { + assert!(oembed_metadata(OEmbedResponse { + title: " ".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: None, + }) + .is_none()); + + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: "Video title".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: Some("not a URL".to_string()), + }) + .unwrap(); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(thumbnail_url, None); + } +}