Skip to content
Clone
main
224 lines 7.6 KB Rust
use url::Url;
use crate::media::{self, MediaKind};
use crate::types::{EmbedAuthor, EmbedMedia, EmbedProvider, MessageEmbed};
use crate::{ResolveContext, UnfurlError, html, http, normalize, oembed};
const OEMBED_MAX_BYTES: usize = 262_144;
pub async fn resolve_page(
context: &ResolveContext<'_>,
) -> Result<Option<MessageEmbed>, UnfurlError> {
Ok(resolve_page_final(context).await?.1)
}
/// 同 [`resolve_page`],但同时返回跟随重定向后的落地地址。
///
/// 短链(`b23.tv` / `t.co`)只有拿到落地页才能重新命中平台注册表并升级成播放器。
pub async fn resolve_page_final(
context: &ResolveContext<'_>,
) -> Result<(Url, Option<MessageEmbed>), UnfurlError> {
let fetched = http::fetch(
context.url.as_str(),
"text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
context.options.max_bytes,
)
.await?;
let final_url = Url::parse(&fetched.final_url).unwrap_or_else(|_| context.url.clone());
if !(200..300).contains(&fetched.status) {
return Ok((final_url, None));
}
let content_type = fetched.content_type.to_ascii_lowercase();
if let Some(kind) =
media::from_content_type(&content_type).or_else(|| media::from_url(&final_url))
{
return Ok((
final_url.clone(),
Some({
let url = &final_url;
let content_type = &content_type;
let media = EmbedMedia {
url: Some(url.to_string()),
content_type: (!content_type.is_empty()).then(|| content_type.to_owned()),
..EmbedMedia::default()
};
let filename = url
.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|value| !value.is_empty())
.map(str::to_owned);
let mut embed = MessageEmbed {
embed_type: kind.embed_type().to_owned(),
url: Some(url.to_string()),
title: filename,
image: (kind == MediaKind::Image).then(|| media.clone()),
video: (kind == MediaKind::Video).then(|| media.clone()),
audio: (kind == MediaKind::Audio).then(|| media.clone()),
image_url: (kind == MediaKind::Image).then(|| url.to_string()),
kind: Some(kind.embed_type().to_owned()),
..MessageEmbed::default()
};
normalize::normalize(&mut embed);
embed
}),
));
}
if !content_type.is_empty() && !content_type.contains("html") && !content_type.contains("xml") {
return Ok((final_url, None));
}
let page = html::parse(&fetched.body, &final_url);
let mut oembed = None;
if let Some(endpoint) = page.oembed.as_deref() {
oembed = oembed::fetch(endpoint, OEMBED_MAX_BYTES).await;
}
if oembed.is_none()
&& let Some(endpoint) = crate::oembed_endpoint(context.url.as_str())
{
oembed = oembed::fetch(&endpoint.endpoint, OEMBED_MAX_BYTES).await;
}
Ok((
final_url.clone(),
build_embed(context, &final_url, page, oembed),
))
}
fn build_embed(
context: &ResolveContext<'_>,
final_url: &Url,
page: html::PageMetadata,
oembed: Option<oembed::OEmbed>,
) -> Option<MessageEmbed> {
let oembed = oembed.unwrap_or_default();
let title = oembed.title.clone().or(page.title);
let description = oembed.description.clone().or(page.description);
let image_url = oembed
.thumbnail_url
.as_deref()
.and_then(|value| http::absolute(final_url, value))
.or(page.image);
let image = image_url.as_ref().map(|url| EmbedMedia {
url: Some(url.clone()),
..EmbedMedia::default()
});
let video = page
.video
.as_deref()
.and_then(|value| http::absolute(final_url, value))
.map(|url| EmbedMedia {
url: Some(url),
..EmbedMedia::default()
});
let audio = page
.audio
.as_deref()
.and_then(|value| http::absolute(final_url, value))
.map(|url| EmbedMedia {
url: Some(url),
..EmbedMedia::default()
});
let player_src = oembed
.html
.as_deref()
.and_then(oembed::iframe_src)
.and_then(|src| http::absolute(final_url, &src))
.and_then(|src| frame_allowed(&src, final_url));
let player = player_src.clone().or_else(|| {
page.player
.clone()
.and_then(|value| http::absolute(final_url, &value))
.and_then(|value| frame_allowed(&value, final_url))
});
let html = player_src.map(|src| {
let src = &src;
let width = oembed.width;
let height = oembed.height;
let width = width.unwrap_or(640);
let height = height.unwrap_or(360);
format!(
"<iframe class=\"gd-embed gd-embed-frame\" src=\"{}\" width=\"{width}\" height=\"{height}\" \
loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen \
sandbox=\"allow-scripts allow-same-origin allow-popups allow-forms allow-presentation\"></iframe>",
crate::escape_html(src),
)
});
let embed_type = if html.is_some() {
"iframe"
} else if video.is_some() {
"video"
} else if audio.is_some() {
"audio"
} else if image.is_some() {
"image"
} else {
"link"
};
let author = oembed.author_name.clone().map(|name| EmbedAuthor {
name,
url: oembed.author_url.clone(),
..EmbedAuthor::default()
});
let provider_name = oembed.provider_name.clone().or(page.site_name.clone());
let provider = provider_name.map(|name| EmbedProvider {
name,
url: oembed.provider_url.clone(),
});
let mut embed = MessageEmbed {
embed_type: embed_type.to_owned(),
url: Some(context.original_url.to_string()),
title,
description,
image: image.clone(),
video,
audio,
author,
provider,
html,
html_width: oembed.width,
html_height: oembed.height,
image_url: image_url.clone(),
site_name: page.site_name.clone(),
src: player,
height: oembed.height,
kind: Some(embed_type.to_owned()),
..MessageEmbed::default()
};
normalize::normalize(&mut embed);
(!embed.is_empty()).then_some(embed)
}
const PLAYER_HOSTS: [&str; 12] = [
"youtube.com",
"youtube-nocookie.com",
"youtu.be",
"vimeo.com",
"soundcloud.com",
"spotify.com",
"twitch.tv",
"twitter.com",
"x.com",
"reddit.com",
"tiktok.com",
"codepen.io",
];
/// iframe 只接受页面自身域名或已知播放器域名:页面自声明的 oEmbed 端点
/// 不应把任意第三方页面塞进消息流。
fn frame_allowed(src: &str, base: &Url) -> Option<String> {
let url = Url::parse(src).ok()?;
if !matches!(url.scheme(), "http" | "https") {
return None;
}
let host = url.host_str()?.trim_end_matches('.').to_ascii_lowercase();
let base_host = base.host_str()?.trim_end_matches('.').to_ascii_lowercase();
let same_site = host == base_host
|| host.ends_with(&format!(".{base_host}"))
|| base_host.ends_with(&format!(".{host}"));
let known = PLAYER_HOSTS
.iter()
.any(|domain| host == *domain || host.ends_with(&format!(".{domain}")));
(same_site || known).then(|| url.to_string())
}