From 039bd1a7dfec1c6f0ae52198def0473ad29e69de Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 16 Aug 2026 14:10:25 +0200 Subject: [PATCH] fix(embed): correct link thumbnail placement and harden unfurl parsing (#1672) --- Cargo.lock | 10 + .../channel_embed/ChannelEmbedShared.ts | 30 - .../channel_embed/EmbedMediaRenderer.tsx | 9 +- .../embeds/channel_embed/RichEmbed.tsx | 6 +- fluxer_unfurl/Cargo.toml | 1 + fluxer_unfurl/src/charset.rs | 155 +++++ fluxer_unfurl/src/html_parser.rs | 417 +++++++++---- fluxer_unfurl/src/main.rs | 1 + fluxer_unfurl/src/oembed.rs | 8 +- .../src/resolvers/default_helpers.rs | 113 ++++ .../src/resolvers/default_resolver.rs | 552 ++++++++++++++++-- fluxer_unfurl/src/resolvers/tenor.rs | 9 +- fluxer_unfurl/src/resolvers/xkcd.rs | 9 +- 13 files changed, 1124 insertions(+), 196 deletions(-) create mode 100644 fluxer_unfurl/src/charset.rs diff --git a/Cargo.lock b/Cargo.lock index 0dea03ed9..c3f4b8df3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1624,6 +1624,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "entities" version = "1.0.1" @@ -1931,6 +1940,7 @@ dependencies = [ "anyhow", "base64", "chrono", + "encoding_rs", "entities", "fluxer-svc", "hmac 0.13.0", diff --git a/fluxer_app/src/features/channel/components/embeds/channel_embed/ChannelEmbedShared.ts b/fluxer_app/src/features/channel/components/embeds/channel_embed/ChannelEmbedShared.ts index 2d79a75e3..2040975e6 100644 --- a/fluxer_app/src/features/channel/components/embeds/channel_embed/ChannelEmbedShared.ts +++ b/fluxer_app/src/features/channel/components/embeds/channel_embed/ChannelEmbedShared.ts @@ -20,11 +20,6 @@ export const EMBED_LEFT_BORDER_WIDTH = 4; export const EMBED_RIGHT_BORDER_WIDTH = 1; export const EMBED_MEDIA_CHROME_WIDTH = EMBED_PADDING_X * 2 + EMBED_LEFT_BORDER_WIDTH + EMBED_RIGHT_BORDER_WIDTH; export const EMBED_MEDIA_MAX_WIDTH = 432; -export const EMBED_MEDIA_CONTENT_WIDTH = EMBED_MEDIA_MAX_WIDTH - EMBED_MEDIA_CHROME_WIDTH; -export const EMBED_MEDIA_FILL_CONSTRAINTS = { - maxWidth: EMBED_MEDIA_CONTENT_WIDTH, - maxHeight: EMBED_MEDIA_CONTENT_WIDTH, -} as const; export interface EmbedProps { embed: MessageEmbed; @@ -88,11 +83,6 @@ export const SAVES_DESCRIPTOR = msg({ message: 'saves', comment: 'Plural external-post stat label on a social post embed. Lowercase to appear after the count.', }); -const thumbnailCalculator = createCalculator({ - maxWidth: THUMBNAIL_SIZE, - maxHeight: THUMBNAIL_SIZE, - forceScale: true, -}); const URL_CACHE_CAPACITY = 4096; const normalizedUrlCache = new Map(); const hostnameCache = new Map(); @@ -138,19 +128,6 @@ export const calculateMediaDimensions = (media: Required): MediaDime const {dimensions} = mediaCalculator.calculate({width: media.width, height: media.height}); return dimensions; }; -export const calculateEmbedImageDimensions = (media: Required): MediaDimensions => { - const naturalWidth = media.width > 0 ? media.width : 1; - const naturalHeight = media.height > 0 ? media.height : 1; - const scale = Math.min( - 1, - EMBED_MEDIA_FILL_CONSTRAINTS.maxWidth / naturalWidth, - EMBED_MEDIA_FILL_CONSTRAINTS.maxHeight / naturalHeight, - ); - return { - width: Math.max(1, Math.round(naturalWidth * scale)), - height: Math.max(1, Math.round(naturalHeight * scale)), - }; -}; export const getOptimizedMediaURL = (proxyURL: string, width: number, height: number, contentType?: string): string => { const targetWidth = Math.round(width * 2); const targetHeight = Math.round(height * 2); @@ -199,13 +176,6 @@ export const mediaPropsEqual = < if (prev.isPreview !== next.isPreview) return false; return embedMediaSignature(prev.embed) === embedMediaSignature(next.embed); }; -export const shouldRenderAsInlineThumbnail = (media?: EmbedMedia): boolean => { - if (!isValidMedia(media)) return false; - const {dimensions: thumbnailDimensions} = thumbnailCalculator.calculate({width: media.width, height: media.height}); - const thumbnailWidth = thumbnailDimensions.width; - const {width: fullWidth} = calculateEmbedImageDimensions(media); - return fullWidth < 300 && thumbnailWidth >= 40; -}; export const isMediaMatureContent = (media?: EmbedMedia): boolean => { if (!media) return false; return Boolean(media.nsfw || ((media.flags ?? 0) & MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA) !== 0); diff --git a/fluxer_app/src/features/channel/components/embeds/channel_embed/EmbedMediaRenderer.tsx b/fluxer_app/src/features/channel/components/embeds/channel_embed/EmbedMediaRenderer.tsx index 2d63f65a4..e3db1d52e 100644 --- a/fluxer_app/src/features/channel/components/embeds/channel_embed/EmbedMediaRenderer.tsx +++ b/fluxer_app/src/features/channel/components/embeds/channel_embed/EmbedMediaRenderer.tsx @@ -8,6 +8,7 @@ import { isMediaMatureContent, isValidMedia, mediaPropsEqual, + THUMBNAIL_SIZE, } from '@app/features/channel/components/embeds/channel_embed/ChannelEmbedShared'; import {EmbedGif} from '@app/features/channel/components/embeds/media/EmbedGifv'; import {EmbedImage} from '@app/features/channel/components/embeds/media/EmbedImage'; @@ -203,7 +204,7 @@ const InlineThumbnailRendererInner: FC = observer( ({embed, message, embedIndex, onDelete, isPreview}) => { if (!embed.thumbnail || !isValidMedia(embed.thumbnail)) return null; const thumbnail = embed.thumbnail; - const width = Math.min(80, Math.round((80 * thumbnail.width) / thumbnail.height)); + const width = Math.min(THUMBNAIL_SIZE, Math.round((THUMBNAIL_SIZE * thumbnail.width) / thumbnail.height)); const thumbnailIsAnimated = thumbnail.content_type === 'image/gif' || (thumbnail.flags & MessageAttachmentFlags.IS_ANIMATED) === MessageAttachmentFlags.IS_ANIMATED; @@ -216,14 +217,14 @@ const InlineThumbnailRendererInner: FC = observer( = observer( [galleryImages, embed, embedIndex, showGallery], ); const shouldRenderMedia = hasAnyMedia || showGallery; - const isRichType = embed.type === MessageEmbedTypes.RICH; - const isInlineThumbnail = - !hasVideo && hasThumbnail && !hasImage && (isRichType || shouldRenderAsInlineThumbnail(embed.thumbnail)); + const promotesThumbnailToImage = embed.type === MessageEmbedTypes.ARTICLE || embed.type === MessageEmbedTypes.IMAGE; + const isInlineThumbnail = !hasVideo && hasThumbnail && !hasImage && !promotesThumbnailToImage; const shouldRenderInlineThumbnail = isInlineThumbnail && !showGallery; const isYouTubeEmbed = getUrlHostname(embed.provider?.url) === 'www.youtube.com'; const useNarrowWidth = shouldRenderMedia && !shouldRenderInlineThumbnail; diff --git a/fluxer_unfurl/Cargo.toml b/fluxer_unfurl/Cargo.toml index cf68876cb..d65fe9480 100644 --- a/fluxer_unfurl/Cargo.toml +++ b/fluxer_unfurl/Cargo.toml @@ -9,6 +9,7 @@ ammonia = "4.1.2" anyhow = "1.0.102" base64 = "0.22.1" chrono = { version = "0.4.45", default-features = false, features = ["std"] } +encoding_rs = "0.8.35" entities = "1.0.1" fluxer-svc = { path = "../fluxer_svc", default-features = false } hmac = "0.13.0" diff --git a/fluxer_unfurl/src/charset.rs b/fluxer_unfurl/src/charset.rs new file mode 100644 index 000000000..030d853b4 --- /dev/null +++ b/fluxer_unfurl/src/charset.rs @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use encoding_rs::{Encoding, UTF_8, WINDOWS_1252}; + +const META_SCAN_LIMIT: usize = 4096; + +pub fn decode_body(bytes: &[u8], content_type: Option<&str>) -> String { + let encoding = Encoding::for_bom(bytes) + .map(|(encoding, _)| encoding) + .or_else(|| content_type.and_then(encoding_from_content_type)) + .or_else(|| encoding_from_meta(bytes)) + .unwrap_or_else(|| sniff_encoding(bytes)); + encoding.decode(bytes).0.into_owned() +} + +fn encoding_from_content_type(content_type: &str) -> Option<&'static Encoding> { + let label = charset_label(content_type)?; + Encoding::for_label(label.as_bytes()) +} + +fn encoding_from_meta(bytes: &[u8]) -> Option<&'static Encoding> { + let window = &bytes[..bytes.len().min(META_SCAN_LIMIT)]; + let text: String = window.iter().map(|&byte| byte as char).collect(); + let text = text.to_ascii_lowercase(); + let mut cursor = 0; + while let Some(offset) = text[cursor..].find("') + .map_or(text.len(), |index| start + index); + if let Some(label) = charset_label(&text[start..end]) + && let Some(encoding) = Encoding::for_label(label.as_bytes()) + { + return Some(encoding); + } + cursor = end.max(start + 1); + } + None +} + +fn charset_label(text: &str) -> Option { + let index = text.to_ascii_lowercase().find("charset")?; + let rest = text[index + "charset".len()..].trim_start(); + let rest = rest.strip_prefix('=')?.trim_start(); + let value = match rest.as_bytes().first()? { + b'"' => rest[1..].split('"').next()?, + b'\'' => rest[1..].split('\'').next()?, + _ => rest + .split(|c: char| c.is_ascii_whitespace() || matches!(c, ';' | '/' | '>' | '"' | '\'')) + .next()?, + }; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn sniff_encoding(bytes: &[u8]) -> &'static Encoding { + if std::str::from_utf8(bytes).is_ok() { + UTF_8 + } else { + WINDOWS_1252 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_charset_wins_over_meta() { + let bytes = b"caf\xc3\xa9"; + let decoded = decode_body(bytes, Some("text/html; charset=utf-8")); + assert!(decoded.contains("café")); + } + + #[test] + fn decodes_shift_jis_from_header() { + let bytes = [0x83, 0x5c, 0x83, 0x6a, 0x81, 0x5b]; + let decoded = decode_body(&bytes, Some("text/html; charset=Shift_JIS")); + assert_eq!(decoded, "ソニー"); + } + + #[test] + fn decodes_shift_jis_from_meta_when_header_has_no_charset() { + let mut bytes = b"".to_vec(); + bytes.extend_from_slice(&[0x83, 0x5c, 0x83, 0x6a, 0x81, 0x5b]); + let decoded = decode_body(&bytes, Some("text/html")); + assert!(decoded.contains("ソニー")); + } + + #[test] + fn decodes_meta_charset_shorthand() { + let mut bytes = b"<html><head><meta charset=windows-1252><title>".to_vec(); + bytes.push(0xe9); + let decoded = decode_body(&bytes, None); + assert!(decoded.contains('é')); + } + + #[test] + fn strips_utf8_bom() { + let decoded = decode_body(b"\xef\xbb\xbfhello", None); + assert_eq!(decoded, "hello"); + } + + #[test] + fn bom_overrides_declared_charset() { + let decoded = decode_body( + b"\xef\xbb\xbfcaf\xc3\xa9", + Some("text/html; charset=Shift_JIS"), + ); + assert_eq!(decoded, "café"); + } + + #[test] + fn decodes_utf16_from_header() { + let bytes = b"\x68\x00\x69\x00"; + assert_eq!(decode_body(bytes, Some("text/html; charset=UTF-16")), "hi"); + } + + #[test] + fn sniffs_utf8_without_declaration() { + assert_eq!(decode_body("café".as_bytes(), None), "café"); + } + + #[test] + fn sniffs_latin1_without_declaration() { + assert_eq!(decode_body(b"caf\xe9", None), "café"); + } + + #[test] + fn declared_utf8_with_invalid_bytes_is_lossy_not_fatal() { + let decoded = decode_body(b"ok\xff", Some("text/html; charset=utf-8")); + assert!(decoded.starts_with("ok")); + assert!(decoded.contains('\u{fffd}')); + } + + #[test] + fn unknown_charset_label_falls_back_to_sniffing() { + assert_eq!( + decode_body(b"caf\xe9", Some("text/html; charset=bogus")), + "café" + ); + } + + #[test] + fn ignores_charset_declared_past_the_scan_window() { + let mut early = b"<meta charset=\"utf-8\">".to_vec(); + early.push(0xe9); + assert!(decode_body(&early, None).contains('\u{fffd}')); + + let mut late = vec![b' '; META_SCAN_LIMIT]; + late.extend_from_slice(b"<meta charset=\"utf-8\">"); + late.push(0xe9); + assert!(decode_body(&late, None).contains('é')); + } +} diff --git a/fluxer_unfurl/src/html_parser.rs b/fluxer_unfurl/src/html_parser.rs index e5735813b..2ace67ea1 100644 --- a/fluxer_unfurl/src/html_parser.rs +++ b/fluxer_unfurl/src/html_parser.rs @@ -2,6 +2,18 @@ use scraper::{Html, Selector}; +const TEXTUAL_KEYS: [&str; 5] = [ + "og:title", + "og:description", + "og:site_name", + "twitter:title", + "twitter:description", +]; + +const CLASSIFYING_CARDS: [&str; 3] = ["summary_large_image", "photo", "player"]; + +const INERT_ELEMENTS: [&str; 4] = ["script", "style", "noscript", "template"]; + #[derive(Debug, Default, Clone)] #[allow(dead_code)] pub struct OgMetadata { @@ -13,17 +25,19 @@ pub struct OgMetadata { pub image_alt: Option<String>, pub image_width: Option<u32>, pub image_height: Option<u32>, - pub video: Option<String>, + pub video_primary: Option<String>, pub audio: Option<String>, pub site_name: Option<String>, pub og_type: Option<String>, pub theme_color: Option<String>, + pub textual_keys_present: bool, } #[derive(Debug, Default, Clone)] #[allow(dead_code)] pub struct TwitterCardMetadata { pub card: Option<String>, + pub classifying_card: Option<String>, pub title: Option<String>, pub description: Option<String>, pub image: Option<String>, @@ -33,48 +47,99 @@ pub struct TwitterCardMetadata { pub player_height: Option<u32>, } +struct MetaTags { + entries: Vec<(String, String)>, +} + +impl MetaTags { + fn parse(doc: &Html) -> Self { + let mut entries = Vec::new(); + let mut stack: Vec<_> = doc.tree.root().children().rev().collect(); + while let Some(node) = stack.pop() { + let Some(element) = node.value().as_element() else { + continue; + }; + if INERT_ELEMENTS.contains(&element.name()) { + continue; + } + stack.extend(node.children().rev()); + if element.name() != "meta" { + continue; + } + let Some(key) = element.attr("property").or_else(|| element.attr("name")) else { + continue; + }; + let Some(value) = element + .attr("content") + .filter(|value| !value.is_empty()) + .or_else(|| element.attr("value").filter(|value| !value.is_empty())) + else { + continue; + }; + entries.push((key.to_owned(), value.to_owned())); + } + Self { entries } + } + + fn all<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a str> { + self.entries + .iter() + .filter(move |(entry_key, _)| entry_key == key) + .map(|(_, value)| value.as_str()) + } + + fn first(&self, key: &str) -> Option<String> { + self.all(key).next().map(ToOwned::to_owned) + } +} + pub fn parse_opengraph(html: &str) -> OgMetadata { let doc = Html::parse_document(html); + let meta = MetaTags::parse(&doc); - let title = extract_meta(&doc, "og:title"); - let description = - extract_meta(&doc, "og:description").or_else(|| extract_meta_by_name(&doc, "description")); - let image = - extract_meta(&doc, "og:image").or_else(|| extract_meta(&doc, "og:image:secure_url")); - let video = extract_meta(&doc, "og:video") - .or_else(|| extract_meta(&doc, "og:video:url")) - .or_else(|| extract_meta(&doc, "og:video:secure_url")) - .or_else(|| extract_meta_by_name(&doc, "twitter:player")) - .or_else(|| extract_meta_by_name(&doc, "twitter:player:stream")); + let description = meta + .first("og:description") + .or_else(|| meta.first("description")); + let image = meta + .first("og:image") + .or_else(|| meta.first("og:image:secure_url")); + let video_primary = meta + .first("og:video") + .or_else(|| meta.first("og:video:url")); let mut og = OgMetadata { - title, + title: meta.first("og:title"), description, - url: extract_meta(&doc, "og:url"), + url: meta.first("og:url"), image, - images: extract_image_urls(&doc), - image_alt: extract_meta(&doc, "og:image:alt") - .or_else(|| extract_meta_by_name(&doc, "twitter:image:alt")) - .or_else(|| extract_meta(&doc, "og:image:description")), - image_width: extract_meta(&doc, "og:image:width").and_then(|v| v.parse().ok()), - image_height: extract_meta(&doc, "og:image:height").and_then(|v| v.parse().ok()), - video, - audio: extract_meta(&doc, "og:audio").or_else(|| extract_meta(&doc, "og:audio:url")), - site_name: extract_meta(&doc, "og:site_name") - .or_else(|| extract_meta_by_name(&doc, "twitter:site:name")) - .or_else(|| extract_meta_by_name(&doc, "application-name")), - og_type: extract_meta(&doc, "og:type"), - theme_color: extract_meta_by_name(&doc, "theme-color"), + images: extract_image_urls(&meta), + image_alt: meta + .first("og:image:alt") + .or_else(|| meta.first("twitter:image:alt")) + .or_else(|| meta.first("og:image:description")), + image_width: meta.first("og:image:width").and_then(|v| v.parse().ok()), + image_height: meta.first("og:image:height").and_then(|v| v.parse().ok()), + video_primary, + audio: meta + .first("og:audio") + .or_else(|| meta.first("og:audio:url")), + site_name: meta + .first("og:site_name") + .or_else(|| meta.first("twitter:site:name")) + .or_else(|| meta.first("application-name")), + og_type: meta.first("og:type"), + theme_color: meta.first("theme-color"), + textual_keys_present: TEXTUAL_KEYS + .iter() + .any(|key| meta.all(key).next().is_some()), }; if og.title.is_none() { - og.title = extract_meta_by_name(&doc, "twitter:title") - .or_else(|| extract_html_title(&doc)) - .or_else(|| extract_meta_by_name(&doc, "title")); + og.title = meta.first("twitter:title"); } if og.description.is_none() { - og.description = extract_meta_by_name(&doc, "twitter:description"); + og.description = meta.first("twitter:description"); } og @@ -83,29 +148,56 @@ pub fn parse_opengraph(html: &str) -> OgMetadata { #[allow(dead_code)] pub fn parse_twitter_card(html: &str) -> TwitterCardMetadata { let doc = Html::parse_document(html); + let meta = MetaTags::parse(&doc); TwitterCardMetadata { - card: extract_meta_by_name(&doc, "twitter:card"), - title: extract_meta_by_name(&doc, "twitter:title"), - description: extract_meta_by_name(&doc, "twitter:description"), - image: extract_meta_by_name(&doc, "twitter:image") - .or_else(|| extract_meta_by_name(&doc, "twitter:image:src")), - image_alt: extract_meta_by_name(&doc, "twitter:image:alt"), - player: extract_meta_by_name(&doc, "twitter:player"), - player_width: extract_meta_by_name(&doc, "twitter:player:width") + card: meta.first("twitter:card"), + classifying_card: meta + .all("twitter:card") + .find(|value| CLASSIFYING_CARDS.contains(value)) + .map(ToOwned::to_owned), + title: meta.first("twitter:title"), + description: meta.first("twitter:description"), + image: meta + .first("twitter:image") + .or_else(|| meta.first("twitter:image:src")), + image_alt: meta.first("twitter:image:alt"), + player: meta.first("twitter:player"), + player_width: meta + .first("twitter:player:width") .and_then(|v| v.parse().ok()), - player_height: extract_meta_by_name(&doc, "twitter:player:height") + player_height: meta + .first("twitter:player:height") .and_then(|v| v.parse().ok()), } } -pub fn extract_html_title(doc: &Html) -> Option<String> { - let sel = Selector::parse("title").ok()?; - doc.select(&sel) - .next() - .map(|el| el.text().collect::<String>()) - .filter(|s| !s.trim().is_empty()) - .map(|s| s.trim().to_owned()) +pub fn find_rsd_url(html: &str) -> Option<String> { + let doc = Html::parse_document(html); + let sel = Selector::parse("link").ok()?; + + for el in doc.select(&sel) { + let Some(link_type) = el.value().attr("type") else { + continue; + }; + if !link_type.eq_ignore_ascii_case("application/rsd+xml") { + continue; + } + let Some(rel) = el.value().attr("rel") else { + continue; + }; + if !rel + .split_whitespace() + .any(|token| token.eq_ignore_ascii_case("EditURI")) + { + continue; + } + if let Some(href) = el.value().attr("href").filter(|href| !href.is_empty()) { + return Some(href.to_owned()); + } + } + + None } pub fn find_activity_pub_link(html: &str) -> Option<String> { @@ -113,13 +205,19 @@ pub fn find_activity_pub_link(html: &str) -> Option<String> { let sel = Selector::parse("link").ok()?; for el in doc.select(&sel) { - let rel = el.value().attr("rel")?.to_ascii_lowercase(); + let Some(rel) = el.value().attr("rel") else { + continue; + }; + let rel = rel.to_ascii_lowercase(); let rel_tokens: Vec<&str> = rel.split_whitespace().collect(); if !rel_tokens.contains(&"alternate") { continue; } - let link_type = el.value().attr("type")?.to_ascii_lowercase(); + let Some(link_type) = el.value().attr("type") else { + continue; + }; + let link_type = link_type.to_ascii_lowercase(); let is_ap = link_type == "application/activity+json" || (link_type == "application/ld+json" && el.value().attr("href").is_some()) || link_type.contains("application/activity+json") @@ -164,7 +262,7 @@ pub fn find_apple_touch_icon(html: &str, base_url: &url::Url) -> Option<String> None } -fn extract_image_urls(doc: &Html) -> Vec<String> { +fn extract_image_urls(meta: &MetaTags) -> Vec<String> { let properties = [ "og:image", "og:image:secure_url", @@ -175,9 +273,9 @@ fn extract_image_urls(doc: &Html) -> Vec<String> { let mut seen = std::collections::HashSet::new(); let mut values = Vec::new(); - for prop in &properties { - for val in extract_meta_values(doc, prop) { - let Some(normalized) = normalize_image_reference_key(&val) else { + for prop in properties { + for val in meta.all(prop) { + let Some(normalized) = normalize_image_reference_key(val) else { continue; }; if seen.insert(normalized) { @@ -211,40 +309,6 @@ fn is_http_url(url: &url::Url) -> bool { matches!(url.scheme(), "http" | "https") } -fn extract_meta_values(doc: &Html, property: &str) -> Vec<String> { - let twitter_property = format!( - "twitter:{}", - property.strip_prefix("og:").unwrap_or(property) - ); - let selectors = [ - format!(r#"meta[property="{property}"]"#), - format!(r#"meta[name="{property}"]"#), - format!(r#"meta[property="{twitter_property}"]"#), - format!(r#"meta[name="{twitter_property}"]"#), - ]; - let mut values = Vec::new(); - for selector_str in &selectors { - if let Ok(sel) = Selector::parse(selector_str) { - for el in doc.select(&sel) { - if let Some(content) = el.value().attr("content") - && !content.is_empty() - { - values.push(content.to_owned()); - } - } - } - } - values -} - -fn extract_meta(doc: &Html, property: &str) -> Option<String> { - extract_meta_values(doc, property).pop() -} - -fn extract_meta_by_name(doc: &Html, name: &str) -> Option<String> { - extract_meta_values(doc, name).pop() -} - #[cfg(test)] mod tests { use super::*; @@ -271,7 +335,7 @@ mod tests { #[test] fn handles_missing_tags() { let m = og("<html><head><title>X"); - assert_eq!(m.title.as_deref(), Some("X")); + assert!(m.title.is_none()); assert!(m.description.is_none() && m.image.is_none() && m.url.is_none()); } @@ -316,15 +380,19 @@ mod tests { } #[test] - fn title_fallback_chain_matches_ts() { + fn title_uses_open_graph_then_twitter_only() { let m = og(r#""#); assert_eq!(m.title.as_deref(), Some("OG")); let m = og(r#""#); assert_eq!(m.title.as_deref(), Some("TW")); + } + + #[test] + fn title_never_falls_back_to_document_title() { let m = og(r#"HTML Title"#); - assert_eq!(m.title.as_deref(), Some("HTML Title")); + assert!(m.title.is_none()); let m = og(r#""#); - assert_eq!(m.title.as_deref(), Some("Meta")); + assert!(m.title.is_none()); } #[test] @@ -348,18 +416,17 @@ mod tests { } #[test] - fn video_url_fallback_chain_matches_ts() { + fn video_primary_only_accepts_og_video_and_og_video_url() { let m = og(r#""#); - assert_eq!(m.video.as_deref(), Some("https://v.com/a.mp4")); + assert_eq!(m.video_primary.as_deref(), Some("https://v.com/a.mp4")); + let m = og(r#""#); + assert_eq!(m.video_primary.as_deref(), Some("https://v.com/b.mp4")); let m = og( - r#""#, + r#""#, ); - assert_eq!(m.video.as_deref(), Some("https://v.com/b.mp4")); + assert!(m.video_primary.is_none()); let m = og(r#""#); - assert_eq!(m.video.as_deref(), Some("https://p.com/embed")); - let m = - og(r#""#); - assert_eq!(m.video.as_deref(), Some("https://s.com/a.mp4")); + assert!(m.video_primary.is_none()); } #[test] @@ -536,21 +603,151 @@ mod tests { assert!(find_canonical_url(h, &base).is_none()); } - #[test] - fn html_title_trims_whitespace() { - let doc = scraper::Html::parse_document(" Hello World "); - assert_eq!(extract_html_title(&doc).as_deref(), Some("Hello World")); - } - - #[test] - fn html_title_empty_returns_none() { - let doc = scraper::Html::parse_document(" "); - assert!(extract_html_title(&doc).is_none()); - } - #[test] fn empty_meta_content_is_skipped() { let m = og(r#""#); assert!(m.title.is_none()); } + + #[test] + fn first_meta_occurrence_wins() { + let h = r#" + + + "#; + assert_eq!(og(h).title.as_deref(), Some("First")); + } + + #[test] + fn property_outranks_name_on_the_same_element() { + let h = r#""#; + let m = og(h); + assert_eq!(m.title.as_deref(), Some("P")); + assert!(m.description.is_none()); + } + + #[test] + fn value_attribute_is_used_when_content_is_absent() { + let m = og(r#""#); + assert_eq!(m.title.as_deref(), Some("V")); + } + + #[test] + fn content_attribute_wins_over_value_attribute() { + let m = og(r#""#); + assert_eq!(m.title.as_deref(), Some("C")); + } + + #[test] + fn meta_keys_are_case_sensitive() { + let m = og(r#""#); + assert!(m.title.is_none()); + let m = og(r#""#); + assert_eq!(m.title.as_deref(), Some("T")); + } + + #[test] + fn meta_values_are_not_trimmed() { + let m = og(r#""#); + assert_eq!(m.title.as_deref(), Some(" T ")); + } + + #[test] + fn og_keys_do_not_alias_to_twitter_keys() { + let m = og(r#""#); + assert!(m.url.is_none()); + } + + #[test] + fn metas_inside_inert_elements_are_skipped() { + for tag in ["noscript", "template"] { + let h = format!( + r#"<{tag}>"# + ); + assert!(og(&h).title.is_none(), "{tag} content must be skipped"); + } + } + + #[test] + fn textual_keys_present_requires_a_discord_textual_key() { + for key in [ + "og:title", + "og:description", + "og:site_name", + "twitter:title", + "twitter:description", + ] { + let h = format!(r#""#); + assert!(og(&h).textual_keys_present, "{key} must satisfy the gate"); + } + for key in [ + "og:image", + "og:url", + "twitter:image", + "twitter:card", + "twitter:site", + "description", + ] { + let h = format!(r#""#); + assert!( + !og(&h).textual_keys_present, + "{key} must not satisfy the gate" + ); + } + } + + #[test] + fn classifying_card_skips_non_assigning_values() { + let h = r#" + + + "#; + let tc = parse_twitter_card(h); + assert_eq!(tc.card.as_deref(), Some("summary")); + assert_eq!(tc.classifying_card.as_deref(), Some("photo")); + } + + #[test] + fn classifying_card_is_case_sensitive_and_untrimmed() { + for value in ["SUMMARY_LARGE_IMAGE", "Summary_Large_Image", "photo "] { + let h = format!(r#""#); + assert!( + parse_twitter_card(&h).classifying_card.is_none(), + "{value} must not classify" + ); + } + } + + #[test] + fn classifying_card_ignores_unknown_values() { + let h = r#""#; + assert!(parse_twitter_card(h).classifying_card.is_none()); + } + + #[test] + fn activity_pub_link_scan_continues_past_unrelated_links() { + let h = r#" + + + "#; + assert_eq!(find_activity_pub_link(h), Some("https://e.com/ap".into())); + } + + #[test] + fn finds_rsd_edit_uri() { + let h = r#""#; + assert_eq!( + find_rsd_url(h), + Some("//w.example/w/api.php?action=rsd".into()) + ); + } + + #[test] + fn rsd_requires_edit_uri_rel_and_rsd_type() { + let h = r#""#; + assert!(find_rsd_url(h).is_none()); + let h = r#""#; + assert!(find_rsd_url(h).is_none()); + assert!(find_rsd_url("").is_none()); + } } diff --git a/fluxer_unfurl/src/main.rs b/fluxer_unfurl/src/main.rs index 91310bfa0..af03caa29 100644 --- a/fluxer_unfurl/src/main.rs +++ b/fluxer_unfurl/src/main.rs @@ -2,6 +2,7 @@ mod activity_pub; mod cache_policy; +mod charset; mod direct_media; mod embed_normalizer; mod html_markdown; diff --git a/fluxer_unfurl/src/oembed.rs b/fluxer_unfurl/src/oembed.rs index 692e998d9..a40f84dd7 100644 --- a/fluxer_unfurl/src/oembed.rs +++ b/fluxer_unfurl/src/oembed.rs @@ -94,7 +94,13 @@ pub async fn fetch_oembed( Ok(response) } OEmbedFormat::Xml => { - let text = String::from_utf8_lossy(&result.bytes); + let text = crate::charset::decode_body( + &result.bytes, + result + .headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + ); parse_oembed_xml(&text) } } diff --git a/fluxer_unfurl/src/resolvers/default_helpers.rs b/fluxer_unfurl/src/resolvers/default_helpers.rs index 9f1b37b2f..f5cfdb329 100644 --- a/fluxer_unfurl/src/resolvers/default_helpers.rs +++ b/fluxer_unfurl/src/resolvers/default_helpers.rs @@ -1,9 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use super::ResolveContext; +use crate::http_fetch; use crate::oembed; +use std::borrow::Cow; +use std::time::Duration; use url::Url; +const MEDIAWIKI_QUERY: &str = + "action=query&prop=extracts&exintro=&explaintext=&format=json&titles="; +const MEDIAWIKI_MAX_BYTES: usize = 256 * 1024; +const MEDIAWIKI_TIMEOUT: Duration = Duration::from_secs(5); + pub struct ImageCandidate { pub url: String, pub width: Option, @@ -120,6 +128,68 @@ pub async fn fetch_oembed_data( .ok() } +#[derive(Debug, Default, PartialEq, Eq)] +pub struct MediaWikiArticle { + pub title: Option, + pub description: Option, +} + +pub async fn fetch_mediawiki_article( + ctx: &ResolveContext<'_>, + html: &str, +) -> Option { + let query_url = mediawiki_query_url(&ctx.url, html)?; + let result = http_fetch::fetch_url( + &ctx.http_client, + &query_url, + MEDIAWIKI_MAX_BYTES, + MEDIAWIKI_TIMEOUT, + ) + .await + .ok()?; + if result.status != 200 { + return None; + } + let json = serde_json::from_slice::(&result.bytes).ok()?; + let pages = json.get("query").and_then(|query| query.get("pages"))?; + if !pages.is_object() && !pages.is_array() { + return None; + } + Some(mediawiki_article(pages)) +} + +fn mediawiki_article(pages: &serde_json::Value) -> MediaWikiArticle { + let page = match pages { + serde_json::Value::Object(pages) => pages.values().next(), + serde_json::Value::Array(pages) => pages.first(), + _ => None, + }; + MediaWikiArticle { + title: page.and_then(|page| mediawiki_text(page, "title")), + description: page.and_then(|page| mediawiki_text(page, "extract")), + } +} + +fn mediawiki_text(page: &serde_json::Value, key: &str) -> Option { + let value = page.get(key)?.as_str()?.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn mediawiki_query_url(page_url: &Url, html: &str) -> Option { + let rsd = crate::html_parser::find_rsd_url(html)?; + let endpoint = page_url.join(&rsd).ok()?; + if !matches!(endpoint.scheme(), "http" | "https") { + return None; + } + let title = page_url + .path_segments()? + .rfind(|segment| !segment.is_empty())?; + let decoded = urlencoding::decode(title).unwrap_or(Cow::Borrowed(title)); + let title = urlencoding::encode(&decoded); + let separator = if endpoint.query().is_some() { '&' } else { '?' }; + Some(format!("{endpoint}{separator}{MEDIAWIKI_QUERY}{title}")) +} + pub fn parse_hex_color(s: &str) -> Option { let hex = s.trim().strip_prefix('#')?; let ok = (hex.len() == 6 || hex.len() == 3) && hex.chars().all(|c| c.is_ascii_hexdigit()); @@ -200,6 +270,49 @@ mod tests { ); } + #[test] + fn mediawiki_query_url_appends_params_to_the_rsd_href() { + let page = url("https://w.example/wiki/Rust"); + let html = r#""#; + assert_eq!( + mediawiki_query_url(&page, html).as_deref(), + Some( + "https://w.example/w/api.php?action=rsd&action=query&prop=extracts&exintro=&explaintext=&format=json&titles=Rust" + ) + ); + } + + #[test] + fn mediawiki_query_url_uses_question_mark_when_rsd_has_no_query() { + let page = url("https://w.example/wiki/Rust/"); + let html = + r#""#; + assert_eq!( + mediawiki_query_url(&page, html).as_deref(), + Some( + "https://w.example/w/api.php?action=query&prop=extracts&exintro=&explaintext=&format=json&titles=Rust" + ) + ); + } + + #[test] + fn mediawiki_query_url_reencodes_the_last_path_segment() { + let page = url("https://w.example/wiki/Rust_%28programming_language%29"); + let html = + r#""#; + assert!( + mediawiki_query_url(&page, html) + .unwrap() + .ends_with("titles=Rust_%28programming_language%29") + ); + } + + #[test] + fn mediawiki_query_url_is_none_without_rsd_link() { + let page = url("https://w.example/wiki/Rust"); + assert!(mediawiki_query_url(&page, "").is_none()); + } + #[test] fn build_image_candidates_deduplicates_after_resolution() { let base = url("https://forgetful.vercel.app/posts/page"); diff --git a/fluxer_unfurl/src/resolvers/default_resolver.rs b/fluxer_unfurl/src/resolvers/default_resolver.rs index 5933f6cdf..23c6bb612 100644 --- a/fluxer_unfurl/src/resolvers/default_resolver.rs +++ b/fluxer_unfurl/src/resolvers/default_resolver.rs @@ -1,12 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use super::default_helpers::{ - build_image_candidates, fetch_oembed_data, parse_hex_color, resolve_media_url, + MediaWikiArticle, build_image_candidates, fetch_mediawiki_article, fetch_oembed_data, + parse_hex_color, resolve_media_url, +}; +use super::media::{ + build_direct_media_embed, media_kind_from_content_type, media_kind_from_response, }; -use super::media::{build_direct_media_embed, media_kind_from_response}; use super::{ResolveContext, Resolver, ResolverResult}; use crate::activity_pub; -use crate::html_parser::{self, OgMetadata}; +use crate::charset; +use crate::direct_media::MediaKind; +use crate::html_parser::{self, OgMetadata, TwitterCardMetadata}; use crate::http_fetch; use crate::media_proxy::embed_media_flags; use crate::oembed; @@ -19,6 +24,21 @@ use url::Url; const MAX_GALLERY_IMAGES: usize = 10; +const PLAYER_HOSTS: [&str; 12] = [ + "youtube.com", + "youtube-nocookie.com", + "youtu.be", + "vimeo.com", + "soundcloud.com", + "twitch.tv", + "streamable.com", + "steampowered.com", + "imgur.com", + "sketchfab.com", + "twitter.com", + "x.com", +]; + pub struct DefaultResolver; impl Resolver for DefaultResolver { @@ -71,7 +91,13 @@ async fn resolve_html(ctx: &ResolveContext<'_>) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result, - og: &OgMetadata, - twitter_card: &html_parser::TwitterCardMetadata, +fn classify_embed_type( + twitter_card: &TwitterCardMetadata, + mediawiki_article: bool, oembed: Option<&oembed::OEmbedResponse>, + twitter_player_allowlisted: bool, + open_graph_video: bool, +) -> &'static str { + if mediawiki_article { + return "article"; + } + match twitter_card.classifying_card.as_deref() { + Some("summary_large_image") => return "article", + Some("photo") => return "image", + Some("player") if twitter_player_allowlisted => { + return "video"; + } + _ => {} + } + if open_graph_video { + return "video"; + } + if oembed + .and_then(|o| optional_oembed_string(o.oembed_type.as_deref())) + .is_some_and(|value| value.eq_ignore_ascii_case("photo")) + { + return "image"; + } + "link" +} + +fn is_allowlisted_player_url(value: &str) -> bool { + let Ok(url) = Url::parse(value) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + let host = host.trim_end_matches('.').to_ascii_lowercase(); + PLAYER_HOSTS + .iter() + .any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}"))) +} + +struct ResolvedPageContent<'a> { + page_url: &'a Url, + embed_type: &'a str, + og: &'a OgMetadata, + mediawiki_article: Option<&'a MediaWikiArticle>, + oembed: Option<&'a oembed::OEmbedResponse>, image_media: Vec, video_media: Option, audio_media: Option, -) -> Vec { - let title = og - .title - .as_deref() - .or(twitter_card.title.as_deref()) +} + +fn build_embeds(content: ResolvedPageContent<'_>) -> Vec { + let ResolvedPageContent { + page_url, + embed_type, + og, + mediawiki_article, + oembed, + image_media, + video_media, + audio_media, + } = content; + let title = mediawiki_article + .and_then(|article| article.title.as_deref()) + .or(og.title.as_deref()) .or(oembed.and_then(|o| optional_oembed_string(o.title.as_deref()))); - let description = og - .description - .as_deref() - .or(twitter_card.description.as_deref()); + let description = mediawiki_article + .and_then(|article| article.description.as_deref()) + .or(og.description.as_deref()); let site_name = oembed .and_then(|o| optional_oembed_string(o.provider_name.as_deref())) .or(og.site_name.as_deref()); let color = og.theme_color.as_deref().and_then(parse_hex_color); - let oembed_type = oembed - .and_then(|o| optional_oembed_string(o.oembed_type.as_deref())) - .map(|value| value.to_ascii_lowercase()); let provider_url = oembed .and_then(|o| optional_absolute_url(o.provider_url.as_deref())) - .unwrap_or_else(|| ctx.url.origin().ascii_serialization()); + .unwrap_or_else(|| page_url.origin().ascii_serialization()); let oembed_html = oembed.and_then(|o| o.html.as_deref()).and_then(|html| { - sanitizer::sanitize_oembed_html_with_context(html, &ctx.url, Some(&provider_url)) + sanitizer::sanitize_oembed_html_with_context(html, page_url, Some(&provider_url)) }); - let embed_url = ctx.url.to_string(); + let embed_url = page_url.to_string(); - let mut embed = MessageEmbed::new(if oembed_html.is_some() { - "rich" - } else { - "link" - }); + let mut embed = MessageEmbed::new(embed_type); embed.url = Some(embed_url.clone()); if let Some(t) = title { embed.title = Some(text_limits::truncate(t, 70)); @@ -189,16 +313,30 @@ fn build_embeds( &mut embed, oembed, &oembed_html, - oembed_type.as_deref(), image_media.as_slice(), video_media, audio_media, ); + if !carries_content(&embed) { + return Vec::new(); + } let mut embeds = vec![embed]; append_gallery_images(&mut embeds, image_media.as_slice(), &embed_url); embeds } +fn carries_content(embed: &MessageEmbed) -> bool { + embed.title.is_some() + || embed.description.is_some() + || embed.author.is_some() + || embed.provider.is_some() + || embed.thumbnail.is_some() + || embed.image.is_some() + || embed.video.is_some() + || embed.audio.is_some() + || embed.html.is_some() +} + fn set_author_and_provider( embed: &mut MessageEmbed, oembed: Option<&oembed::OEmbedResponse>, @@ -227,17 +365,12 @@ fn set_media( embed: &mut MessageEmbed, oembed: Option<&oembed::OEmbedResponse>, oembed_html: &Option, - oembed_type: Option<&str>, image_media: &[EmbedMedia], video_media: Option, audio_media: Option, ) { if let Some(primary) = image_media.first().cloned() { - if oembed_type == Some("photo") { - embed.image = Some(primary); - } else { - embed.thumbnail = Some(primary); - } + embed.thumbnail = Some(primary); } embed.video = video_media; embed.audio = audio_media; @@ -267,6 +400,7 @@ async fn resolve_image_media( &candidate.url, candidate.width, candidate.height, + MediaKind::Image, ) .await { @@ -276,11 +410,19 @@ async fn resolve_image_media( resolved } +fn player_embed_media(url: String) -> EmbedMedia { + EmbedMedia { + url: Some(url), + ..Default::default() + } +} + async fn resolve_media_with_metadata( ctx: &ResolveContext<'_>, url: Option<&str>, + expected_kind: MediaKind, ) -> Option { - resolve_media_with_metadata_and_size(ctx, url?, None, None).await + resolve_media_with_metadata_and_size(ctx, url?, None, None, expected_kind).await } async fn resolve_media_with_metadata_and_size( @@ -288,9 +430,18 @@ async fn resolve_media_with_metadata_and_size( url: &str, width: Option, height: Option, + expected_kind: MediaKind, ) -> Option { let nsfw_str = crate::media_proxy::MediaProxyClient::nsfw_mode_str(ctx.nsfw_mode); match ctx.media_proxy.get_metadata(url, nsfw_str).await { + Ok(m) if media_kind_from_content_type(&m.content_type) != Some(expected_kind) => { + tracing::warn!( + url, + content_type = %m.content_type, + "default embed media metadata returned an unexpected media kind" + ); + None + } Ok(m) => Some(EmbedMedia { url: Some(url.to_owned()), width: width.or(m.width), @@ -557,4 +708,315 @@ mod tests { &url("https://e.com/page") )); } + + fn classify(html: &str) -> &'static str { + classify_with(html, false, None, false) + } + + fn classify_with( + html: &str, + mediawiki_article: bool, + oembed_type: Option<&str>, + video_validated: bool, + ) -> &'static str { + let oembed = oembed_type.map(|value| oembed::OEmbedResponse { + oembed_type: Some(value.to_owned()), + ..Default::default() + }); + let page = url("https://example.com/page"); + let og = html_parser::parse_opengraph(html); + let twitter_card = html_parser::parse_twitter_card(html); + let player_url = twitter_card + .classifying_card + .as_deref() + .is_some_and(|card| card == "player") + .then(|| { + twitter_card + .player + .as_deref() + .and_then(|value| resolve_media_url(&page, value)) + }) + .flatten() + .filter(|value| is_allowlisted_player_url(value)); + let open_graph_video_url = og + .og_type + .as_deref() + .is_some_and(|og_type| og_type.starts_with("video.")) + .then(|| { + og.video_primary + .as_deref() + .and_then(|value| resolve_media_url(&page, value)) + }) + .flatten(); + let open_graph_video = open_graph_video_url.is_some() + && (open_graph_video_url + .as_deref() + .is_some_and(is_allowlisted_player_url) + || video_validated); + classify_embed_type( + &twitter_card, + mediawiki_article, + oembed.as_ref(), + player_url.is_some(), + open_graph_video, + ) + } + + #[test] + fn classify_defaults_to_link() { + assert_eq!( + classify(r#""#), + "link" + ); + } + + #[test] + fn classify_oembed_rich_html_no_longer_forces_rich() { + assert_eq!( + classify_with( + r#""#, + false, + Some("rich"), + false + ), + "link" + ); + for oembed_type in ["video", "link", "audio"] { + assert_eq!( + classify_with( + r#""#, + false, + Some(oembed_type), + false + ), + "link", + "oembed {oembed_type} must not change the type" + ); + } + } + + #[test] + fn classify_oembed_photo_is_image() { + assert_eq!( + classify_with( + r#""#, + false, + Some("photo"), + false + ), + "image" + ); + } + + #[test] + fn classify_twitter_card_summary_large_image_is_article() { + assert_eq!( + classify(r#""#), + "article" + ); + } + + #[test] + fn classify_twitter_card_photo_is_image() { + assert_eq!( + classify(r#""#), + "image" + ); + } + + #[test] + fn classify_twitter_card_summary_is_a_no_op() { + assert_eq!( + classify(r#""#), + "link" + ); + } + + #[test] + fn classify_twitter_player_requires_allowlisted_host() { + let allowed = r#" + + + "#; + assert_eq!(classify(allowed), "video"); + + let denied = r#" + + + "#; + assert_eq!(classify(denied), "link"); + } + + #[test] + fn classify_open_graph_video_requires_video_og_type() { + let html = r#" + + + "#; + assert_eq!(classify(html), "video"); + + let without_type = r#" + + "#; + assert_eq!(classify(without_type), "link"); + + for og_type in ["music.song", "article", "website"] { + let html = format!( + r#" + + + "# + ); + assert_eq!(classify(&html), "link", "{og_type} must not be video"); + } + } + + #[test] + fn classify_open_graph_video_secure_url_alone_does_not_qualify() { + let html = r#" + + + "#; + assert_eq!(classify(html), "link"); + } + + #[test] + fn classify_open_graph_direct_video_requires_validation() { + let html = r#" + + + "#; + assert_eq!(classify_with(html, false, None, false), "link"); + assert_eq!(classify_with(html, false, None, true), "video"); + } + + #[test] + fn classify_mediawiki_beats_every_twitter_card() { + for card in ["summary_large_image", "photo", "player"] { + let html = format!( + r#" + + + "# + ); + assert_eq!(classify_with(&html, true, None, false), "article"); + } + } + + #[test] + fn classify_twitter_card_beats_open_graph_video() { + let html = r#" + + + + "#; + assert_eq!(classify(html), "image"); + } + + #[test] + fn classify_open_graph_video_beats_oembed_photo() { + let html = r#" + + + "#; + assert_eq!(classify_with(html, false, Some("photo"), false), "video"); + } + + #[test] + fn player_allowlist_accepts_verified_hosts() { + for value in [ + "https://www.youtube.com/embed/x", + "https://www.youtube-nocookie.com/embed/x", + "https://youtu.be/x", + "https://player.vimeo.com/video/1", + "https://w.soundcloud.com/player/?url=x", + "https://player.twitch.tv/?video=1", + ] { + assert!(is_allowlisted_player_url(value), "{value} must be allowed"); + } + } + + #[test] + fn classify_twitter_player_resolves_relative_references_first() { + let html = r#" + + + "#; + assert_eq!(classify(html), "video"); + } + + fn build(embed_type: &str, og: &OgMetadata) -> Vec { + let page_url = url("https://example.com/page"); + build_embeds(ResolvedPageContent { + page_url: &page_url, + embed_type, + og, + mediawiki_article: None, + oembed: None, + image_media: Vec::new(), + video_media: None, + audio_media: None, + }) + } + + #[test] + fn drops_a_classified_embed_that_carries_no_content() { + for embed_type in ["article", "image", "link"] { + assert!( + build(embed_type, &OgMetadata::default()).is_empty(), + "{embed_type}" + ); + } + } + + #[test] + fn keeps_a_classified_embed_whose_only_content_is_a_site_name() { + let og = OgMetadata { + site_name: Some("Example".to_owned()), + ..Default::default() + }; + let embeds = build("link", &og); + assert_eq!(embeds.len(), 1); + assert!(embeds[0].provider.is_some()); + } + + #[test] + fn prefers_mediawiki_text_over_open_graph_text() { + let page_url = url("https://w.example/wiki/Rust"); + let og = OgMetadata { + title: Some("T".to_owned()), + description: Some("D".to_owned()), + ..Default::default() + }; + let article = MediaWikiArticle { + title: Some("Probe Page".to_owned()), + description: Some("Extract text.".to_owned()), + }; + let embeds = build_embeds(ResolvedPageContent { + page_url: &page_url, + embed_type: "article", + og: &og, + mediawiki_article: Some(&article), + oembed: None, + image_media: Vec::new(), + video_media: None, + audio_media: None, + }); + assert_eq!(embeds.len(), 1); + assert_eq!(embeds[0].title.as_deref(), Some("Probe Page")); + assert_eq!(embeds[0].description.as_deref(), Some("Extract text.")); + } + + #[test] + fn player_allowlist_rejects_other_hosts() { + for value in [ + "https://embed.ted.com/talks/1", + "https://example.com/player", + "https://notyoutube.com/embed/x", + "//www.youtube.com/embed/x", + "not a url", + ] { + assert!(!is_allowlisted_player_url(value), "{value} must be denied"); + } + } } diff --git a/fluxer_unfurl/src/resolvers/tenor.rs b/fluxer_unfurl/src/resolvers/tenor.rs index 51745229c..0eff0452c 100644 --- a/fluxer_unfurl/src/resolvers/tenor.rs +++ b/fluxer_unfurl/src/resolvers/tenor.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use super::{ResolveContext, Resolver, ResolverResult}; +use crate::charset; use crate::html_parser; use crate::http_fetch; use crate::media_proxy::{MediaMetadata, MediaProxyClient, embed_media_flags}; @@ -40,7 +41,13 @@ impl Resolver for TenorResolver { return Ok(ResolverResult { embeds: vec![] }); } - let html = String::from_utf8_lossy(&result.bytes); + let html = charset::decode_body( + &result.bytes, + result + .headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + ); let og = html_parser::parse_opengraph(&html); let nsfw_str = MediaProxyClient::nsfw_mode_str(ctx.nsfw_mode); let json_ld = extract_json_ld_urls(&html); diff --git a/fluxer_unfurl/src/resolvers/xkcd.rs b/fluxer_unfurl/src/resolvers/xkcd.rs index 6d18fb38e..2c25e473f 100644 --- a/fluxer_unfurl/src/resolvers/xkcd.rs +++ b/fluxer_unfurl/src/resolvers/xkcd.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use super::{ResolveContext, Resolver, ResolverResult}; +use crate::charset; use crate::html_parser; use crate::http_fetch; use crate::media_proxy::{MediaMetadata, MediaProxyClient, embed_media_flags}; @@ -39,7 +40,13 @@ impl Resolver for XkcdResolver { return Ok(ResolverResult { embeds: vec![] }); } - let html = String::from_utf8_lossy(&result.bytes); + let html = charset::decode_body( + &result.bytes, + result + .headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + ); let og = html_parser::parse_opengraph(&html); let title = og.title.as_deref().map(|t| parse_text(t, 70));