fix(embed): correct link thumbnail placement and harden unfurl parsing (#1672)

This commit is contained in:
Hampus
2026-08-16 14:10:25 +02:00
committed by GitHub
parent 7a45d5844d
commit 039bd1a7df
13 changed files with 1124 additions and 196 deletions
Generated
+10
View File
@@ -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",
@@ -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<string, string | null>();
const hostnameCache = new Map<string, string | null>();
@@ -138,19 +128,6 @@ export const calculateMediaDimensions = (media: Required<EmbedMedia>): MediaDime
const {dimensions} = mediaCalculator.calculate({width: media.width, height: media.height});
return dimensions;
};
export const calculateEmbedImageDimensions = (media: Required<EmbedMedia>): 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);
@@ -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<EmbedMediaRendererProps> = 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<EmbedMediaRendererProps> = observer(
<EmbedImage
src={
thumbnail.content_type === 'image/gif'
? buildAnimatedImageProxyURL(thumbnail.proxy_url, width * 2, 160)
: getOptimizedMediaURL(thumbnail.proxy_url, width, 80, thumbnail.content_type)
? buildAnimatedImageProxyURL(thumbnail.proxy_url, width * 2, THUMBNAIL_SIZE * 2)
: getOptimizedMediaURL(thumbnail.proxy_url, width, THUMBNAIL_SIZE, thumbnail.content_type)
}
originalSrc={thumbnail.url}
naturalWidth={thumbnail.width}
naturalHeight={thumbnail.height}
width={width}
height={80}
height={THUMBNAIL_SIZE}
placeholder={thumbnail.placeholder}
constrain={true}
isInline={true}
@@ -10,7 +10,6 @@ import {
getBorderColor,
getUrlHostname,
isValidMedia,
shouldRenderAsInlineThumbnail,
} from '@app/features/channel/components/embeds/channel_embed/ChannelEmbedShared';
import {
EmbedMediaRenderer,
@@ -48,9 +47,8 @@ export const RichEmbed: FC<EmbedProps> = 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;
+1
View File
@@ -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"
+155
View File
@@ -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("<meta") {
let start = cursor + offset;
let end = text[start..]
.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<String> {
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"<html><head><meta charset=\"iso-8859-1\"><title>caf\xc3\xa9</title>";
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"<html><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=Shift_JIS\"><title>".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('é'));
}
}
+307 -110
View File
@@ -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</title></head></html>");
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#"<head><meta property="og:title" content="OG"></head>"#);
assert_eq!(m.title.as_deref(), Some("OG"));
let m = og(r#"<head><meta name="twitter:title" content="TW"></head>"#);
assert_eq!(m.title.as_deref(), Some("TW"));
}
#[test]
fn title_never_falls_back_to_document_title() {
let m = og(r#"<html><head><title>HTML Title</title></head></html>"#);
assert_eq!(m.title.as_deref(), Some("HTML Title"));
assert!(m.title.is_none());
let m = og(r#"<head><meta name="title" content="Meta"></head>"#);
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#"<head><meta property="og:video" content="https://v.com/a.mp4"></head>"#);
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#"<head><meta property="og:video:url" content="https://v.com/b.mp4"></head>"#);
assert_eq!(m.video_primary.as_deref(), Some("https://v.com/b.mp4"));
let m = og(
r#"<head><meta property="og:video:secure_url" content="https://v.com/b.mp4"></head>"#,
r#"<head><meta property="og:video:secure_url" content="https://v.com/c.mp4"></head>"#,
);
assert_eq!(m.video.as_deref(), Some("https://v.com/b.mp4"));
assert!(m.video_primary.is_none());
let m = og(r#"<head><meta name="twitter:player" content="https://p.com/embed"></head>"#);
assert_eq!(m.video.as_deref(), Some("https://p.com/embed"));
let m =
og(r#"<head><meta name="twitter:player:stream" content="https://s.com/a.mp4"></head>"#);
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("<title> Hello World </title>");
assert_eq!(extract_html_title(&doc).as_deref(), Some("Hello World"));
}
#[test]
fn html_title_empty_returns_none() {
let doc = scraper::Html::parse_document("<title> </title>");
assert!(extract_html_title(&doc).is_none());
}
#[test]
fn empty_meta_content_is_skipped() {
let m = og(r#"<head><meta property="og:title" content=""></head>"#);
assert!(m.title.is_none());
}
#[test]
fn first_meta_occurrence_wins() {
let h = r#"<head>
<meta property="og:title" content="First">
<meta property="og:title" content="Second">
</head>"#;
assert_eq!(og(h).title.as_deref(), Some("First"));
}
#[test]
fn property_outranks_name_on_the_same_element() {
let h = r#"<head><meta property="og:title" name="og:description" content="P"></head>"#;
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#"<head><meta property="og:title" value="V"></head>"#);
assert_eq!(m.title.as_deref(), Some("V"));
}
#[test]
fn content_attribute_wins_over_value_attribute() {
let m = og(r#"<head><meta property="og:title" content="C" value="V"></head>"#);
assert_eq!(m.title.as_deref(), Some("C"));
}
#[test]
fn meta_keys_are_case_sensitive() {
let m = og(r#"<head><meta property="OG:TITLE" content="T"></head>"#);
assert!(m.title.is_none());
let m = og(r#"<head><meta PROPERTY="og:title" CONTENT="T"></head>"#);
assert_eq!(m.title.as_deref(), Some("T"));
}
#[test]
fn meta_values_are_not_trimmed() {
let m = og(r#"<head><meta property="og:title" content=" T "></head>"#);
assert_eq!(m.title.as_deref(), Some(" T "));
}
#[test]
fn og_keys_do_not_alias_to_twitter_keys() {
let m = og(r#"<head><meta name="twitter:url" content="https://e.com/p"></head>"#);
assert!(m.url.is_none());
}
#[test]
fn metas_inside_inert_elements_are_skipped() {
for tag in ["noscript", "template"] {
let h = format!(
r#"<head><{tag}><meta property="og:title" content="Hidden"></{tag}></head>"#
);
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#"<head><meta property="{key}" content="X"></head>"#);
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#"<head><meta property="{key}" content="X"></head>"#);
assert!(
!og(&h).textual_keys_present,
"{key} must not satisfy the gate"
);
}
}
#[test]
fn classifying_card_skips_non_assigning_values() {
let h = r#"<head>
<meta name="twitter:card" content="summary">
<meta name="twitter:card" content="photo">
</head>"#;
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#"<head><meta name="twitter:card" content="{value}"></head>"#);
assert!(
parse_twitter_card(&h).classifying_card.is_none(),
"{value} must not classify"
);
}
}
#[test]
fn classifying_card_ignores_unknown_values() {
let h = r#"<head><meta name="twitter:card" content="summary"></head>"#;
assert!(parse_twitter_card(h).classifying_card.is_none());
}
#[test]
fn activity_pub_link_scan_continues_past_unrelated_links() {
let h = r#"<head>
<link rel="stylesheet" href="/a.css">
<link rel="alternate" type="application/activity+json" href="https://e.com/ap">
</head>"#;
assert_eq!(find_activity_pub_link(h), Some("https://e.com/ap".into()));
}
#[test]
fn finds_rsd_edit_uri() {
let h = r#"<head><link rel="EditURI" type="application/rsd+xml" href="//w.example/w/api.php?action=rsd"></head>"#;
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#"<head><link rel="alternate" type="application/rsd+xml" href="/rsd"></head>"#;
assert!(find_rsd_url(h).is_none());
let h = r#"<head><link rel="EditURI" type="application/xml" href="/rsd"></head>"#;
assert!(find_rsd_url(h).is_none());
assert!(find_rsd_url("<head></head>").is_none());
}
}
+1
View File
@@ -2,6 +2,7 @@
mod activity_pub;
mod cache_policy;
mod charset;
mod direct_media;
mod embed_normalizer;
mod html_markdown;
+7 -1
View File
@@ -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)
}
}
@@ -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<u32>,
@@ -120,6 +128,68 @@ pub async fn fetch_oembed_data(
.ok()
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct MediaWikiArticle {
pub title: Option<String>,
pub description: Option<String>,
}
pub async fn fetch_mediawiki_article(
ctx: &ResolveContext<'_>,
html: &str,
) -> Option<MediaWikiArticle> {
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::<serde_json::Value>(&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<String> {
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<String> {
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<u32> {
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#"<head><link rel="EditURI" type="application/rsd+xml" href="//w.example/w/api.php?action=rsd"></head>"#;
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#"<head><link rel="EditURI" type="application/rsd+xml" href="/w/api.php"></head>"#;
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#"<head><link rel="EditURI" type="application/rsd+xml" href="/w/api.php"></head>"#;
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, "<head></head>").is_none());
}
#[test]
fn build_image_candidates_deduplicates_after_resolution() {
let base = url("https://forgetful.vercel.app/posts/page");
+507 -45
View File
@@ -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<ResolverResult
return Ok(ResolverResult { embeds: vec![] });
}
let html = String::from_utf8_lossy(bytes);
let html = charset::decode_body(
bytes,
result
.headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
);
let final_ctx = ResolveContext {
url: final_url.clone(),
@@ -106,6 +132,8 @@ async fn resolve_html(ctx: &ResolveContext<'_>) -> anyhow::Result<ResolverResult
let og = html_parser::parse_opengraph(&html);
let twitter_card = html_parser::parse_twitter_card(&html);
let mediawiki_article = fetch_mediawiki_article(&final_ctx, &html).await;
let oembed_data = fetch_oembed_data(&final_ctx, &html).await;
let mut image_media = resolve_image_media(&final_ctx, &og, oembed_data.as_ref()).await;
@@ -114,68 +142,164 @@ async fn resolve_html(ctx: &ResolveContext<'_>) -> anyhow::Result<ResolverResult
{
first.description = Some(text_limits::truncate(alt, 4096));
}
let video_url = og
.video
let player_url = twitter_card
.classifying_card
.as_deref()
.and_then(|url| resolve_media_url(&final_ctx.url, url));
let video_media = resolve_media_with_metadata(&final_ctx, video_url.as_deref()).await;
.is_some_and(|card| card == "player")
.then(|| {
twitter_card
.player
.as_deref()
.and_then(|url| resolve_media_url(&final_ctx.url, url))
})
.flatten()
.filter(|url| is_allowlisted_player_url(url));
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(|url| resolve_media_url(&final_ctx.url, url))
})
.flatten();
let open_graph_video_allowlisted = open_graph_video_url
.as_deref()
.is_some_and(is_allowlisted_player_url);
let open_graph_video_media = if open_graph_video_allowlisted {
open_graph_video_url.clone().map(player_embed_media)
} else {
resolve_media_with_metadata(
&final_ctx,
open_graph_video_url.as_deref(),
MediaKind::Video,
)
.await
};
let open_graph_video_accepted = open_graph_video_media.is_some();
let video_media = player_url
.clone()
.map(player_embed_media)
.or(open_graph_video_media);
let audio_url = og
.audio
.as_deref()
.and_then(|url| resolve_media_url(&final_ctx.url, url));
let audio_media = resolve_media_with_metadata(&final_ctx, audio_url.as_deref()).await;
let audio_media =
resolve_media_with_metadata(&final_ctx, audio_url.as_deref(), MediaKind::Audio).await;
let embeds = build_embeds(
&final_ctx,
&og,
let embed_type = classify_embed_type(
&twitter_card,
mediawiki_article.is_some(),
oembed_data.as_ref(),
player_url.is_some(),
open_graph_video_accepted,
);
if embed_type == "link" && !og.textual_keys_present {
return Ok(ResolverResult { embeds: vec![] });
}
let embeds = build_embeds(ResolvedPageContent {
page_url: &final_ctx.url,
embed_type,
og: &og,
mediawiki_article: mediawiki_article.as_ref(),
oembed: oembed_data.as_ref(),
image_media,
video_media,
audio_media,
);
});
Ok(ResolverResult { embeds })
}
fn build_embeds(
ctx: &ResolveContext<'_>,
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<EmbedMedia>,
video_media: Option<EmbedMedia>,
audio_media: Option<EmbedMedia>,
) -> Vec<MessageEmbed> {
let title = og
.title
.as_deref()
.or(twitter_card.title.as_deref())
}
fn build_embeds(content: ResolvedPageContent<'_>) -> Vec<MessageEmbed> {
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<String>,
oembed_type: Option<&str>,
image_media: &[EmbedMedia],
video_media: Option<EmbedMedia>,
audio_media: Option<EmbedMedia>,
) {
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<EmbedMedia> {
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<u32>,
height: Option<u32>,
expected_kind: MediaKind,
) -> Option<EmbedMedia> {
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#"<head><meta property="og:title" content="T"></head>"#),
"link"
);
}
#[test]
fn classify_oembed_rich_html_no_longer_forces_rich() {
assert_eq!(
classify_with(
r#"<head><meta property="og:title" content="T"></head>"#,
false,
Some("rich"),
false
),
"link"
);
for oembed_type in ["video", "link", "audio"] {
assert_eq!(
classify_with(
r#"<head><meta property="og:title" content="T"></head>"#,
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#"<head><meta property="og:title" content="T"></head>"#,
false,
Some("photo"),
false
),
"image"
);
}
#[test]
fn classify_twitter_card_summary_large_image_is_article() {
assert_eq!(
classify(r#"<head><meta name="twitter:card" content="summary_large_image"></head>"#),
"article"
);
}
#[test]
fn classify_twitter_card_photo_is_image() {
assert_eq!(
classify(r#"<head><meta name="twitter:card" content="photo"></head>"#),
"image"
);
}
#[test]
fn classify_twitter_card_summary_is_a_no_op() {
assert_eq!(
classify(r#"<head><meta name="twitter:card" content="summary"></head>"#),
"link"
);
}
#[test]
fn classify_twitter_player_requires_allowlisted_host() {
let allowed = r#"<head>
<meta name="twitter:card" content="player">
<meta name="twitter:player" content="https://player.vimeo.com/video/1">
</head>"#;
assert_eq!(classify(allowed), "video");
let denied = r#"<head>
<meta name="twitter:card" content="player">
<meta name="twitter:player" content="https://embed.ted.com/talks/1">
</head>"#;
assert_eq!(classify(denied), "link");
}
#[test]
fn classify_open_graph_video_requires_video_og_type() {
let html = r#"<head>
<meta property="og:type" content="video.other">
<meta property="og:video" content="https://www.youtube.com/embed/x">
</head>"#;
assert_eq!(classify(html), "video");
let without_type = r#"<head>
<meta property="og:video" content="https://www.youtube.com/embed/x">
</head>"#;
assert_eq!(classify(without_type), "link");
for og_type in ["music.song", "article", "website"] {
let html = format!(
r#"<head>
<meta property="og:type" content="{og_type}">
<meta property="og:video" content="https://www.youtube.com/embed/x">
</head>"#
);
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#"<head>
<meta property="og:type" content="video.other">
<meta property="og:video:secure_url" content="https://www.youtube.com/embed/x">
</head>"#;
assert_eq!(classify(html), "link");
}
#[test]
fn classify_open_graph_direct_video_requires_validation() {
let html = r#"<head>
<meta property="og:type" content="video.other">
<meta property="og:video" content="https://e.com/clip.mp4">
</head>"#;
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#"<head>
<meta name="twitter:card" content="{card}">
<meta name="twitter:player" content="https://player.twitch.tv/?video=1">
</head>"#
);
assert_eq!(classify_with(&html, true, None, false), "article");
}
}
#[test]
fn classify_twitter_card_beats_open_graph_video() {
let html = r#"<head>
<meta name="twitter:card" content="photo">
<meta property="og:type" content="video.other">
<meta property="og:video" content="https://www.youtube.com/embed/x">
</head>"#;
assert_eq!(classify(html), "image");
}
#[test]
fn classify_open_graph_video_beats_oembed_photo() {
let html = r#"<head>
<meta property="og:type" content="video.other">
<meta property="og:video" content="https://www.youtube.com/embed/x">
</head>"#;
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#"<head>
<meta name="twitter:card" content="player">
<meta name="twitter:player" content="//player.vimeo.com/video/1">
</head>"#;
assert_eq!(classify(html), "video");
}
fn build(embed_type: &str, og: &OgMetadata) -> Vec<MessageEmbed> {
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");
}
}
}
+8 -1
View File
@@ -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);
+8 -1
View File
@@ -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));