mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
466 lines
16 KiB
Rust
466 lines
16 KiB
Rust
// 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};
|
|
use crate::types::{EmbedMedia, EmbedProvider, MessageEmbed};
|
|
use scraper::{Html, Selector};
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::time::Duration;
|
|
use url::Url;
|
|
|
|
const TENOR_JSON_LD_MAX_BYTES: usize = 256 * 1024;
|
|
const TENOR_STATIC_PNG_THUMBNAIL_SUFFIX: &str = "AAAAe";
|
|
const TENOR_ANIMATED_WEBP_THUMBNAIL_SUFFIX: &str = "AAAA1";
|
|
|
|
pub struct TenorResolver;
|
|
|
|
impl Resolver for TenorResolver {
|
|
fn matches(&self, url: &Url) -> bool {
|
|
url.host_str()
|
|
.is_some_and(|h| h.eq_ignore_ascii_case("tenor.com"))
|
|
}
|
|
|
|
fn resolve<'a>(
|
|
&'a self,
|
|
ctx: &'a ResolveContext<'_>,
|
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<ResolverResult>> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
let result = http_fetch::fetch_url(
|
|
&ctx.http_client,
|
|
ctx.url.as_str(),
|
|
http_fetch::DEFAULT_HTML_MAX_BYTES,
|
|
Duration::from_secs(10),
|
|
)
|
|
.await?;
|
|
|
|
if result.status != 200 {
|
|
return Ok(ResolverResult { embeds: vec![] });
|
|
}
|
|
|
|
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);
|
|
let thumbnail_url = json_ld
|
|
.as_ref()
|
|
.and_then(|urls| urls.thumbnail_url.clone())
|
|
.or_else(|| {
|
|
og.image
|
|
.as_deref()
|
|
.filter(|url| is_gif_url(url))
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
.map(|url| tenor_webp_thumbnail_url(&url).unwrap_or(url));
|
|
let video_url = json_ld.and_then(|urls| urls.video_url);
|
|
|
|
if thumbnail_url.is_none() && video_url.is_none() {
|
|
return Ok(ResolverResult { embeds: vec![] });
|
|
}
|
|
|
|
let thumbnail_future = async {
|
|
match thumbnail_url.as_deref() {
|
|
Some(url) => resolve_media_url(ctx, url, nsfw_str).await,
|
|
None => None,
|
|
}
|
|
};
|
|
let video_future = async {
|
|
match video_url.as_deref() {
|
|
Some(url) => resolve_media_url(ctx, url, nsfw_str).await,
|
|
None => None,
|
|
}
|
|
};
|
|
let (thumbnail, video) = tokio::join!(thumbnail_future, video_future);
|
|
let Some(embed) = tenor_embed(&ctx.url, thumbnail, video) else {
|
|
return Ok(ResolverResult { embeds: vec![] });
|
|
};
|
|
|
|
Ok(ResolverResult {
|
|
embeds: vec![embed],
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
fn tenor_embed(
|
|
source_url: &Url,
|
|
thumbnail: Option<EmbedMedia>,
|
|
video: Option<EmbedMedia>,
|
|
) -> Option<MessageEmbed> {
|
|
if thumbnail.is_none() && video.is_none() {
|
|
return None;
|
|
}
|
|
|
|
let mut embed = MessageEmbed::new("gifv");
|
|
embed.url = Some(source_url.to_string());
|
|
embed.provider = Some(EmbedProvider {
|
|
name: "Tenor".to_owned(),
|
|
url: Some("https://tenor.com".to_owned()),
|
|
});
|
|
embed.thumbnail = thumbnail;
|
|
embed.video = video;
|
|
Some(embed)
|
|
}
|
|
|
|
async fn resolve_media_url(
|
|
ctx: &ResolveContext<'_>,
|
|
media_url: &str,
|
|
nsfw_mode: &str,
|
|
) -> Option<EmbedMedia> {
|
|
let resolved_url = match ctx.url.join(media_url) {
|
|
Ok(url) if matches!(url.scheme(), "http" | "https") => url.to_string(),
|
|
Ok(url) => {
|
|
tracing::warn!(url = %url, "rejected unsafe Tenor media URL");
|
|
return None;
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(error = %err, url = media_url, "failed to resolve Tenor media URL");
|
|
return None;
|
|
}
|
|
};
|
|
let meta = match ctx.media_proxy.get_metadata(&resolved_url, nsfw_mode).await {
|
|
Ok(meta) => meta,
|
|
Err(err) => {
|
|
tracing::warn!(error = %err, url = resolved_url, "failed to enrich Tenor media metadata");
|
|
return None;
|
|
}
|
|
};
|
|
Some(build_embed_media_payload(&resolved_url, &meta))
|
|
}
|
|
|
|
fn build_embed_media_payload(url: &str, metadata: &MediaMetadata) -> EmbedMedia {
|
|
EmbedMedia {
|
|
url: Some(url.to_owned()),
|
|
width: metadata.width,
|
|
height: metadata.height,
|
|
placeholder: metadata.placeholder.clone(),
|
|
flags: embed_media_flags(metadata),
|
|
content_hash: Some(metadata.content_hash.clone()),
|
|
content_type: Some(metadata.content_type.clone()),
|
|
duration: metadata.duration.map(|duration| duration as u32),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct TenorJsonLdUrls {
|
|
thumbnail_url: Option<String>,
|
|
video_url: Option<String>,
|
|
}
|
|
|
|
fn extract_json_ld_urls(html: &str) -> Option<TenorJsonLdUrls> {
|
|
let doc = Html::parse_document(html);
|
|
let selector = Selector::parse(r#"script.dynamic[type="application/ld+json"]"#).ok()?;
|
|
|
|
for script in doc.select(&selector) {
|
|
let json_str = script.text().collect::<String>();
|
|
if json_str.len() > TENOR_JSON_LD_MAX_BYTES {
|
|
continue;
|
|
}
|
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&json_str) {
|
|
let image = value
|
|
.get("image")
|
|
.and_then(|i| i.get("thumbnailUrl"))
|
|
.and_then(|v| v.as_str())
|
|
.and_then(valid_absolute_url)
|
|
.or_else(|| {
|
|
value
|
|
.get("image")
|
|
.and_then(|i| i.get("contentUrl"))
|
|
.and_then(|v| v.as_str())
|
|
.and_then(valid_absolute_url)
|
|
});
|
|
let video = value
|
|
.get("video")
|
|
.and_then(|v| v.get("contentUrl"))
|
|
.and_then(|v| v.as_str())
|
|
.and_then(valid_absolute_url);
|
|
|
|
return Some(TenorJsonLdUrls {
|
|
thumbnail_url: image,
|
|
video_url: video,
|
|
});
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn is_gif_url(value: &str) -> bool {
|
|
Url::parse(value)
|
|
.map(|url| url.path().to_ascii_lowercase().ends_with(".gif"))
|
|
.unwrap_or_else(|_| value.to_ascii_lowercase().ends_with(".gif"))
|
|
}
|
|
|
|
fn tenor_webp_thumbnail_url(value: &str) -> Option<String> {
|
|
let mut url = Url::parse(value).ok()?;
|
|
if !url
|
|
.host_str()
|
|
.is_some_and(|host| host.eq_ignore_ascii_case("media.tenor.com"))
|
|
{
|
|
return None;
|
|
}
|
|
if !url.path().to_ascii_lowercase().ends_with(".png") {
|
|
return None;
|
|
}
|
|
|
|
let mut path_segments = url
|
|
.path_segments()?
|
|
.map(ToOwned::to_owned)
|
|
.collect::<Vec<_>>();
|
|
if path_segments.len() != 2 {
|
|
return None;
|
|
}
|
|
|
|
let media_key = path_segments.first_mut()?;
|
|
let media_id = media_key.strip_suffix(TENOR_STATIC_PNG_THUMBNAIL_SUFFIX)?;
|
|
*media_key = format!("{media_id}{TENOR_ANIMATED_WEBP_THUMBNAIL_SUFFIX}");
|
|
|
|
let filename = path_segments.last_mut()?;
|
|
filename.truncate(filename.len() - ".png".len());
|
|
filename.push_str(".webp");
|
|
|
|
url.set_path(&format!("/{}", path_segments.join("/")));
|
|
Some(url.to_string())
|
|
}
|
|
|
|
fn valid_absolute_url(value: &str) -> Option<String> {
|
|
let url = Url::parse(value).ok()?;
|
|
if matches!(url.scheme(), "http" | "https") {
|
|
Some(value.to_owned())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn extract_json_ld_urls_ignores_oversized_script_blocks() {
|
|
let html = format!(
|
|
r#"<script class="dynamic" type="application/ld+json">{}</script>"#,
|
|
"x".repeat(TENOR_JSON_LD_MAX_BYTES + 1)
|
|
);
|
|
assert_eq!(extract_json_ld_urls(&html), None);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_json_ld_urls_extracts_media() {
|
|
let html = r#"<script class="dynamic" type="application/ld+json">{"image":{"contentUrl":"https://tenor.example/a.gif","thumbnailUrl":"https://tenor.example/a.png"},"video":{"contentUrl":"https://tenor.example/a.mp4"}}</script>"#;
|
|
assert_eq!(
|
|
extract_json_ld_urls(html),
|
|
Some(TenorJsonLdUrls {
|
|
thumbnail_url: Some("https://tenor.example/a.png".to_owned()),
|
|
video_url: Some("https://tenor.example/a.mp4".to_owned())
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_json_ld_urls_requires_dynamic_script_like_ts() {
|
|
let html = r#"<script type="application/ld+json">{"image":{"thumbnailUrl":"https://tenor.example/a.png"}}</script>"#;
|
|
assert_eq!(extract_json_ld_urls(html), None);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_json_ld_urls_keeps_valid_json_ld_without_media_urls() {
|
|
let html =
|
|
r#"<script class="dynamic" type="application/ld+json">{"name":"empty"}</script>"#;
|
|
assert_eq!(
|
|
extract_json_ld_urls(html),
|
|
Some(TenorJsonLdUrls {
|
|
thumbnail_url: None,
|
|
video_url: None
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_json_ld_urls_falls_back_to_thumbnail_url() {
|
|
let html = r#"<script class="dynamic" type="application/ld+json">{"image":{"thumbnailUrl":"https://tenor.example/a.png"}}</script>"#;
|
|
assert_eq!(
|
|
extract_json_ld_urls(html),
|
|
Some(TenorJsonLdUrls {
|
|
thumbnail_url: Some("https://tenor.example/a.png".to_owned()),
|
|
video_url: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gif_url_detection_uses_pathname() {
|
|
assert!(is_gif_url("https://media.tenor.com/a/b/c.gif?cache=1"));
|
|
assert!(!is_gif_url("https://media.tenor.com/a/b/c.gifv"));
|
|
}
|
|
|
|
#[test]
|
|
fn tenor_webp_thumbnail_url_rewrites_static_png_preview() {
|
|
assert_eq!(
|
|
tenor_webp_thumbnail_url("https://media.tenor.com/pdX9YTI4_eoAAAAe/cat.png").as_deref(),
|
|
Some("https://media.tenor.com/pdX9YTI4_eoAAAA1/cat.webp")
|
|
);
|
|
assert_eq!(
|
|
tenor_webp_thumbnail_url("https://media.tenor.com/pdX9YTI4_eoAAAAe/cat.PNG").as_deref(),
|
|
Some("https://media.tenor.com/pdX9YTI4_eoAAAA1/cat.webp")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tenor_webp_thumbnail_url_preserves_query_string() {
|
|
assert_eq!(
|
|
tenor_webp_thumbnail_url(
|
|
"https://media.tenor.com/pdX9YTI4_eoAAAAe/cat.png?hh=320&ww=320"
|
|
)
|
|
.as_deref(),
|
|
Some("https://media.tenor.com/pdX9YTI4_eoAAAA1/cat.webp?hh=320&ww=320")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tenor_webp_thumbnail_url_rejects_unknown_shapes() {
|
|
assert!(tenor_webp_thumbnail_url("https://example.com/pdX9YTI4_eoAAAAe/cat.png").is_none());
|
|
assert!(
|
|
tenor_webp_thumbnail_url("https://media.tenor.com/pdX9YTI4_eoAAAAM/cat.gif").is_none()
|
|
);
|
|
assert!(
|
|
tenor_webp_thumbnail_url("https://media.tenor.com/pdX9YTI4_eoAAAAe/cat.jpg").is_none()
|
|
);
|
|
assert!(
|
|
tenor_webp_thumbnail_url("https://media.tenor.com/nested/pdX9YTI4_eoAAAAe/cat.png")
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_embed_media_payload_keeps_metadata_fields() {
|
|
let meta = MediaMetadata {
|
|
format: "mp4".to_owned(),
|
|
content_type: "video/mp4".to_owned(),
|
|
content_hash: "hash".to_owned(),
|
|
size: 123,
|
|
width: Some(320),
|
|
height: Some(240),
|
|
duration: Some(1.5),
|
|
placeholder: Some("placeholder".to_owned()),
|
|
animated: Some(true),
|
|
nsfw: true,
|
|
nsfw_probability: None,
|
|
};
|
|
let media = build_embed_media_payload("https://tenor.example/a.mp4", &meta);
|
|
assert_eq!(media.content_type.as_deref(), Some("video/mp4"));
|
|
assert_eq!(media.content_hash.as_deref(), Some("hash"));
|
|
assert_eq!(media.width, Some(320));
|
|
assert_eq!(media.height, Some(240));
|
|
assert_eq!(media.duration, Some(1));
|
|
assert_eq!(media.placeholder.as_deref(), Some("placeholder"));
|
|
assert_eq!(media.flags, (1 << 4) | (1 << 5));
|
|
}
|
|
|
|
#[test]
|
|
fn tenor_embed_returns_none_without_enriched_media() {
|
|
let source_url = Url::parse("https://tenor.com/view/cat-gif-12345").unwrap();
|
|
|
|
assert!(tenor_embed(&source_url, None, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tenor_embed_keeps_enriched_media() {
|
|
let source_url = Url::parse("https://tenor.com/view/cat-gif-12345").unwrap();
|
|
let video = EmbedMedia {
|
|
url: Some("https://media.tenor.com/cat.mp4".to_owned()),
|
|
content_type: Some("video/mp4".to_owned()),
|
|
..Default::default()
|
|
};
|
|
|
|
let embed = tenor_embed(&source_url, None, Some(video)).expect("embed");
|
|
|
|
assert_eq!(embed.embed_type, "gifv");
|
|
assert_eq!(embed.url.as_deref(), Some(source_url.as_str()));
|
|
assert!(embed.thumbnail.is_none());
|
|
assert_eq!(
|
|
embed.video.as_ref().and_then(|media| media.url.as_deref()),
|
|
Some("https://media.tenor.com/cat.mp4")
|
|
);
|
|
assert_eq!(
|
|
embed
|
|
.provider
|
|
.as_ref()
|
|
.map(|provider| provider.name.as_str()),
|
|
Some("Tenor")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn matches_tenor_com_only() {
|
|
let r = TenorResolver;
|
|
assert!(r.matches(&Url::parse("https://tenor.com/view/cat-gif-12345").unwrap()));
|
|
assert!(!r.matches(&Url::parse("https://media.tenor.com/images/abc.gif").unwrap()));
|
|
assert!(!r.matches(&Url::parse("https://nottenor.com/view/test").unwrap()));
|
|
}
|
|
|
|
#[test]
|
|
fn is_gif_url_with_query_params() {
|
|
assert!(is_gif_url("https://media.tenor.com/a/b.gif?hh=320&ww=320"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_gif_url_rejects_non_gif() {
|
|
assert!(!is_gif_url("https://media.tenor.com/a/b.mp4"));
|
|
assert!(!is_gif_url("https://media.tenor.com/a/b.png"));
|
|
assert!(!is_gif_url("https://media.tenor.com/a/b.webp"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_gif_url_case_insensitive() {
|
|
assert!(is_gif_url("https://media.tenor.com/a/b.GIF"));
|
|
}
|
|
|
|
#[test]
|
|
fn valid_absolute_url_rejects_non_http() {
|
|
assert!(valid_absolute_url("ftp://example.com/file").is_none());
|
|
assert!(valid_absolute_url("javascript:alert(1)").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn valid_absolute_url_accepts_http_and_https() {
|
|
assert!(valid_absolute_url("http://example.com/file").is_some());
|
|
assert!(valid_absolute_url("https://example.com/file").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn valid_absolute_url_rejects_relative() {
|
|
assert!(valid_absolute_url("/relative/path").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_json_ld_urls_with_content_url_and_thumbnail() {
|
|
let html = r#"<script class="dynamic" type="application/ld+json">{"image":{"contentUrl":"https://t.example/content.gif","thumbnailUrl":"https://t.example/thumb.png"}}</script>"#;
|
|
let urls = extract_json_ld_urls(html).unwrap();
|
|
assert_eq!(
|
|
urls.thumbnail_url.as_deref(),
|
|
Some("https://t.example/thumb.png")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_json_ld_urls_prefers_thumbnail_url_over_content_url() {
|
|
let html = r#"<script class="dynamic" type="application/ld+json">{"image":{"contentUrl":"https://t.example/a.gif","thumbnailUrl":"https://t.example/thumb.png"}}</script>"#;
|
|
let urls = extract_json_ld_urls(html).unwrap();
|
|
assert_eq!(
|
|
urls.thumbnail_url.as_deref(),
|
|
Some("https://t.example/thumb.png")
|
|
);
|
|
}
|
|
}
|