mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
fix(embed): correct link thumbnail placement and harden unfurl parsing (#1672)
This commit is contained in:
Generated
+10
@@ -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);
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod activity_pub;
|
||||
mod cache_policy;
|
||||
mod charset;
|
||||
mod direct_media;
|
||||
mod embed_normalizer;
|
||||
mod html_markdown;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user