Skip to content
Clone

feat(chat): follow redirects, decode by charset, honour page oEmbed

The first embed pass could only read a page that answered 200 as ASCII
UTF-8 at the exact URL typed, which excludes most of what people paste:

- outbound now returns the body *and* the final URL, follows redirects
  for the unfurl path (b23.tv / t.co / xianyu shorteners only resolve to
  content at all, and the landed host decides which platform renders),
  sends a browser User-Agent because zhihu/weibo/csdn answer 403 to the
  default one, and accepts gzip/brotli/deflate.
- decode_body takes charset from content-type, tries UTF-8 when absent
  and falls back to GB18030, so GBK pages stop producing mojibake cards.
- card extraction: twitter: tags back up a missing og:, JSON-LD
  (incl. @graph and object-form image) backs up both, protocol-relative
  and root-relative images are absolutised (//n.sinaimg.cn would else
  resolve against our own page and never load), and trailing punctuation
  is trimmed by bracket counting so the `)` of
  /wiki/Rust_(programming_language) stays part of the URL.
- unfurl now prefers a page-declared
  `<link rel="alternate" type="application/json+oembed">` over nothing,
  which covers platforms the registry never hardcodes; the endpoint is
  re-validated through the SSRF guard before it is fetched.
- chat_link_unfurl gains final_url and oembed so a cache hit still knows
  the landed host and the discovered endpoint instead of re-parsing.

Ordering inside resolve_link: registries that embed without a request
(YouTube/Spotify/Bilibili features) never touch the network; everything
else follows the redirect first and upgrades to a player when the landed
page belongs to an embeddable platform.
ZhenYi committed
parent 88a5ec8 commit 31962ad Browse files

7 files changed

+638 −142

File tree 7
Modified lib/chat/src/services/oembed.rs View lib/chat/src/services/oembed.rs
@@ -6,21 +6,42 @@
66
77use social::Enrichment;
88
9use crate::services::outbound::fetch_text;
9use crate::services::outbound::fetch_following;
10use crate::services::outbound::validate_outbound_url;
1011
1112const MAX_BYTES: usize = 262_144;
1213
1314/// 抓取 URL 所属平台的 oEmbed 数据;平台没声明端点或抓取失败时返回 `None`。
1415pub async fn fetch(url: &str) -> Option<Enrichment> {
1516 let endpoint = social::oembed_endpoint(url)?;
16 let body = fetch_text(
17 let body = fetch_following(
1718 &endpoint.endpoint,
1819 "application/json",
1920 Some("json"),
2021 MAX_BYTES,
2122 )
2223 .await
23 .ok()?;
24 .ok()?
25 .body;
26 parse(&body)
27}
28
29/// 抓取页面自己声明的 oEmbed 端点。
30///
31/// 比 [`fetch`] 覆盖面大得多:注册表只硬编码了十几个平台,而页面里的
32/// `<link rel="alternate" type="application/json+oembed">` 是平台自己发布的,
33/// 新增平台不需要跟着改代码。
34pub async fn fetch_endpoint(endpoint: &str) -> Option<Enrichment> {
35 if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
36 return None;
37 }
38 if validate_outbound_url(endpoint).is_err() {
39 return None;
40 }
41 let body = fetch_following(endpoint, "application/json", Some("json"), MAX_BYTES)
42 .await
43 .ok()?
44 .body;
2445 parse(&body)
2546}
2647
Modified lib/chat/src/services/outbound.rs View lib/chat/src/services/outbound.rs
@@ -14,6 +14,10 @@
1414
1515const MAX_DELIVERY_ATTEMPTS: i32 = 5;
1616
17/// 多数站点(知乎、微博、CSDN)会对默认爬虫 UA 直接 403,浏览器 UA 才有正常 HTML。
18const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
19 Chrome/131.0.0.0 Safari/537.36";
20
1721#[derive(Debug, Clone, Serialize, ToSchema)]
1822pub struct OutboundWebhookDto {
1923 pub id: Uuid,
@@ -124,66 +128,140 @@
124128 }
125129}
126130
127/// SSRF 防护后的 GET:只允许公网地址、不跟随重定向、限时限量。
131/// 抓取结果:正文 + 最终落地的 URL。
132///
133/// 短链(`b23.tv` / `t.co` / `xhslink.com`)只有跟随重定向才能拿到真实内容,
134/// 而落地地址又决定这条链接该按哪个平台渲染,所以两者一起返回。
135pub(crate) struct Fetched {
136 pub body: String,
137 pub final_url: String,
138}
139
140/// 从 `content-type` 里摘 charset;缺失时按 `None`(交给调用方嗅探)。
141fn charset_of(content_type: &str) -> Option<String> {
142 let lower = content_type.to_ascii_lowercase();
143 let start = lower.find("charset=")? + "charset=".len();
144 let rest = &content_type[start..];
145 let value = rest
146 .split(|c: char| c == ';' || c == ',' || c.is_whitespace())
147 .next()
148 .unwrap_or("")
149 .trim_matches(|c: char| c == '"' || c == '\'');
150 (!value.is_empty()).then(|| value.to_string())
151}
152
153/// 按声明编码解码;没声明时先看 UTF-8 能否直通,否则回退 GB18030(中文站常见)。
154pub(crate) fn decode_body(body: &[u8], content_type: &str) -> String {
155 if let Some(label) = charset_of(content_type)
156 && let Some(encoding) = encoding_rs::Encoding::for_label(label.as_bytes())
157 {
158 let (text, _, _) = encoding.decode(body);
159 return text.into_owned();
160 }
161 match std::str::from_utf8(body) {
162 Ok(text) => text.to_string(),
163 Err(_) => {
164 let (text, _, _) = encoding_rs::GB18030.decode(body);
165 text.into_owned()
166 }
167 }
168}
169
170/// SSRF 防护后的 GET:只允许公网地址、限时限量,跟随重定向并对每一跳重做校验。
128171///
129172/// `expect_content_type` 非空时,响应 `content-type` 必须包含该片段(读 body 之前检查)。
130pub(crate) async fn fetch_text(
173/// 重定向是手工跟的:`reqwest` 自带的跳转策略不会拦截落到内网地址的 `Location`,
174/// 所以每一跳都要重新过一遍 [`validate_outbound_url`] 与 DNS 归属检查。
175pub(crate) async fn fetch_following(
131176 url: &str,
132177 accept: &str,
133178 expect_content_type: Option<&str>,
134179 max_bytes: usize,
135) -> Result<String, String> {
136 let uri = validate_outbound_url(url).map_err(|e| e.to_string())?;
137 let host = uri.host().unwrap_or("").to_string();
138 let port = uri.port_u16();
139 let addrs = tokio::net::lookup_host((host.as_str(), port.unwrap_or(443)))
140 .await
141 .map_err(|e| format!("dns resolution failed: {e}"))?
142 .collect::<Vec<_>>();
143 if addrs.is_empty() {
144 return Err("dns resolution returned no address".to_string());
145 }
146 if addrs.iter().any(|addr| !is_public_ip(&addr.ip())) {
147 return Err("url resolves to a private or loopback address".to_string());
148 }
149 let mut client_builder = reqwest::Client::builder()
150 .timeout(std::time::Duration::from_secs(10))
151 .redirect(reqwest::redirect::Policy::none())
152 .user_agent("gitdata-chat-fetch/1.0");
153 for addr in &addrs {
154 client_builder = client_builder.resolve(&host, *addr);
155 }
156 let client = client_builder.build().map_err(|e| e.to_string())?;
157 let response = client
158 .get(url)
159 .header("accept", accept)
160 .send()
161 .await
162 .map_err(|e| format!("request failed: {e}"))?;
163 if !response.status().is_success() {
164 return Err(format!("endpoint returned {}", response.status().as_u16()));
165 }
166 let content_type = response
167 .headers()
168 .get("content-type")
169 .and_then(|value| value.to_str().ok())
170 .unwrap_or("")
171 .to_lowercase();
172 if let Some(expected) = expect_content_type
173 && !content_type.is_empty()
174 && !content_type.contains(expected)
175 {
176 return Err(format!("unexpected content-type {content_type}"));
177 }
178 let mut body: Vec<u8> = Vec::new();
179 let mut stream = response;
180 while let Some(chunk) = stream.chunk().await.map_err(|e| e.to_string())? {
181 body.extend_from_slice(&chunk);
182 if body.len() >= max_bytes {
183 break;
180) -> Result<Fetched, String> {
181 fetch(url, accept, expect_content_type, max_bytes).await
182}
183
184const MAX_REDIRECTS: usize = 6;
185
186async fn fetch(
187 url: &str,
188 accept: &str,
189 expect_content_type: Option<&str>,
190 max_bytes: usize,
191) -> Result<Fetched, String> {
192 let mut current = url.to_string();
193 for _ in 0..=MAX_REDIRECTS {
194 let uri = validate_outbound_url(&current).map_err(|e| e.to_string())?;
195 let host = uri.host().unwrap_or("").to_string();
196 let port = uri.port_u16();
197 let addrs = tokio::net::lookup_host((host.as_str(), port.unwrap_or(443)))
198 .await
199 .map_err(|e| format!("dns resolution failed: {e}"))?
200 .collect::<Vec<_>>();
201 if addrs.is_empty() {
202 return Err("dns resolution returned no address".to_string());
203 }
204 if addrs.iter().any(|addr| !is_public_ip(&addr.ip())) {
205 return Err("url resolves to a private or loopback address".to_string());
206 }
207 let mut client_builder = reqwest::Client::builder()
208 .timeout(std::time::Duration::from_secs(10))
209 .redirect(reqwest::redirect::Policy::none())
210 .user_agent(USER_AGENT);
211 for addr in &addrs {
212 client_builder = client_builder.resolve(&host, *addr);
213 }
214 let client = client_builder.build().map_err(|e| e.to_string())?;
215 let response = client
216 .get(&current)
217 .header("accept", accept)
218 .send()
219 .await
220 .map_err(|e| format!("request failed: {e}"))?;
221
222 if response.status().is_redirection() {
223 let location = response
224 .headers()
225 .get("location")
226 .and_then(|value| value.to_str().ok())
227 .ok_or_else(|| "redirect without a location header".to_string())?;
228 let base = url::Url::parse(&current).map_err(|e| e.to_string())?;
229 current = base
230 .join(location)
231 .map_err(|e| format!("invalid redirect target: {e}"))?
232 .to_string();
233 continue;
234 }
235 if !response.status().is_success() {
236 return Err(format!("endpoint returned {}", response.status().as_u16()));
184237 }
238 let content_type = response
239 .headers()
240 .get("content-type")
241 .and_then(|value| value.to_str().ok())
242 .unwrap_or("")
243 .to_string();
244 let lowered = content_type.to_ascii_lowercase();
245 if let Some(expected) = expect_content_type
246 && !lowered.is_empty()
247 && !lowered.contains(expected)
248 {
249 return Err(format!("unexpected content-type {lowered}"));
250 }
251 let mut body: Vec<u8> = Vec::new();
252 let mut stream = response;
253 while let Some(chunk) = stream.chunk().await.map_err(|e| e.to_string())? {
254 body.extend_from_slice(&chunk);
255 if body.len() >= max_bytes {
256 break;
257 }
258 }
259 return Ok(Fetched {
260 body: decode_body(&body, &content_type),
261 final_url: current,
262 });
185263 }
186 Ok(String::from_utf8_lossy(&body).to_string())
264 Err("too many redirects".to_string())
187265}
188266
189267async fn require_room_manager(
Modified lib/chat/src/services/unfurl.rs View lib/chat/src/services/unfurl.rs
@@ -6,7 +6,7 @@
66use uuid::Uuid;
77
88use crate::services::oembed;
9use crate::services::outbound::fetch_text;
9use crate::services::outbound::fetch_following;
1010use crate::services::service::ChatService;
1111use crate::share::notify::current_room_seq;
1212use crate::share::realtime::RoomEvent;
@@ -17,35 +17,61 @@
1717const FAILED_RETRY_MINUTES: i64 = 60;
1818
1919fn url_hash(url: &str) -> String {
20 let mut hasher = Sha256::new();
21 hasher.update(url.as_bytes());
22 hasher
23 .finalize()
24 .iter()
25 .map(|b| format!("{b:02x}"))
26 .collect()
20 let mut hasher = Sha256::new();
21 hasher.update(url.as_bytes());
22 hasher
23 .finalize()
24 .iter()
25 .map(|b| format!("{b:02x}"))
26 .collect()
27 }
28
29/// 裁掉句尾标点,但保留括号配对时的右括号。
30///
31/// `https://en.wikipedia.org/wiki/Rust_(programming_language)` 末尾的 `)` 属于路径;
32/// `(详见 https://example.com/a)` 末尾的 `)` 不属于。按括号计数决定去留。
33fn trim_trailing_punctuation(value: &str) -> String {
34 let mut end = value.len();
35 while end > 0 {
36 let Some(ch) = value[..end].chars().next_back() else {
37 break;
38 };
39 let drop = match ch {
40 ')' => value[..end].matches('(').count() < value[..end].matches(')').count(),
41 ']' => value[..end].matches('[').count() < value[..end].matches(']').count(),
42 '}' => value[..end].matches('{').count() < value[..end].matches('}').count(),
43 '.' | ',' | '>' | '"' | '\'' | ';' | '!' | '?' | '。' | ',' | '、' | ')'
44 | '】' | '》' | '」' | '』' => true,
45 _ => false,
46 };
47 if !drop {
48 break;
49 }
50 end -= ch.len_utf8();
51 }
52 value[..end].to_string()
2753}
2854
2955fn extract_urls(content: &str) -> Vec<String> {
3056 let mut urls = Vec::new();
3157 for token in content.split_whitespace() {
32 let trimmed = token.trim_matches(|c: char| {
33 !c.is_alphanumeric()
34 && !matches!(
35 c,
36 ':' | '/' | '.' | '-' | '_' | '?' | '&' | '=' | '%' | '~' | '+' | '#' | '@'
37 )
58 // 只裁掉 URL 前面的引用符号:括号可能是路径的一部分
59 // (`.../Rust_(programming_language)`),先剥掉会把链接截断。
60 let trimmed = token.trim_start_matches(|c: char| {
61 matches!(c, '(' | '[' | '{' | '<' | '"' | '\'' | '「' | '『' | '《')
3862 });
3963 if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
4064 continue;
4165 }
42 let cleaned = trimmed
43 .trim_end_matches(|c: char| matches!(c, '.' | ',' | ')' | ']' | '>' | '"' | '\''));
66 if trimmed.len() < 12 || trimmed.len() > 2048 {
67 continue;
68 }
69 let cleaned = trim_trailing_punctuation(trimmed);
4470 if cleaned.len() < 12 || cleaned.len() > 2048 {
4571 continue;
4672 }
47 if !urls.iter().any(|u: &String| u == cleaned) {
48 urls.push(cleaned.to_string());
73 if !urls.iter().any(|u: &String| *u == cleaned) {
74 urls.push(cleaned);
4975 }
5076 if urls.len() >= MAX_URLS_PER_MESSAGE {
5177 break;
@@ -85,6 +111,16 @@
85111 }
86112}
87113
114/// 依次尝试多个 `meta` 键,返回第一个非空值。
115///
116/// 只认 `og:` 会让大量站点拿到空卡片:很多新闻/电商站点只发 `twitter:` 卡片,
117/// 或者只留一个裸 `description`。
118fn meta_any(head: &str, properties: &[&str]) -> Option<String> {
119 properties
120 .iter()
121 .find_map(|property| meta_content(head, property))
122}
123
88124fn page_title(head: &str) -> Option<String> {
89125 let lower = head.to_ascii_lowercase();
90126 let start = lower.find("<title")?;
@@ -94,6 +130,128 @@
94130 (!title.is_empty()).then(|| decode_entities(title).chars().take(200).collect())
95131}
96132
133/// 从 `<link rel="alternate" type="application/json+oembed" href="...">` 取出站点
134/// 自己声明的 oEmbed 端点。
135///
136/// 注册表里硬编码的端点只有十几个;页面自声明的端点覆盖面大得多,而且平台改了
137/// 端点也不用跟着发版。
138fn link_oembed(head: &str) -> Option<String> {
139 let lower = head.to_ascii_lowercase();
140 let mut search_from = 0;
141 loop {
142 let idx = lower[search_from..].find("<link")? + search_from;
143 let end = lower[idx..].find('>')? + idx;
144 let tag = &head[idx..=end];
145 let tag_lower = &lower[idx..=end];
146 search_from = end + 1;
147 if !tag_lower.contains("oembed") {
148 continue;
149 }
150 // 只认 JSON 形态:XML 端点的解析成本远大于收益。
151 if !tag_lower.contains("json") {
152 continue;
153 }
154 for attr in ["href=\"", "href='"] {
155 if let Some(start) = tag.find(attr) {
156 let value_start = start + attr.len();
157 let quote = attr.chars().last()?;
158 if let Some(value_end) = tag[value_start..].find(quote) {
159 let value = tag[value_start..value_start + value_end].trim();
160 if !value.is_empty() {
161 return Some(decode_entities(value));
162 }
163 }
164 }
165 }
166 }
167}
168
169/// JSON-LD 里能当卡片用的字段(`headline` / `name` / `description` / `image`)。
170///
171/// `image` 可能是字符串、`{"url": ...}`,或者这两者的数组。
172fn json_ld_value(block: &serde_json::Value, key: &str) -> Option<String> {
173 let node = block.get(key)?;
174 let text = match node {
175 serde_json::Value::String(value) => value.clone(),
176 serde_json::Value::Object(map) => map.get("url")?.as_str()?.to_string(),
177 serde_json::Value::Array(items) => items.first().and_then(|item| match item {
178 serde_json::Value::String(value) => Some(value.clone()),
179 serde_json::Value::Object(map) => map.get("url")?.as_str().map(str::to_string),
180 _ => None,
181 })?,
182 _ => return None,
183 };
184 let trimmed = text.trim();
185 (!trimmed.is_empty()).then(|| decode_entities(trimmed))
186}
187
188/// 扫描页面里的 `application/ld+json`,取第一个带可用字段的对象。
189///
190/// 不少站点(`@type: NewsArticle` 之类)不发 OpenGraph 却一定有结构化数据。
191fn json_ld_meta(head: &str) -> (Option<String>, Option<String>, Option<String>) {
192 let lower = head.to_ascii_lowercase();
193 let mut search_from = 0;
194 while let Some(idx) = lower[search_from..]
195 .find("<script")
196 .map(|i| i + search_from)
197 {
198 let Some(end) = lower[idx..].find('>').map(|i| i + idx) else {
199 break;
200 };
201 let open_tag = &lower[idx..=end];
202 search_from = end + 1;
203 if !open_tag.contains("application/ld+json") {
204 continue;
205 }
206 let Some(close) = lower[search_from..]
207 .find("</script>")
208 .map(|i| i + search_from)
209 else {
210 break;
211 };
212 let raw = &head[search_from..close];
213 search_from = close + "</script>".len();
214 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw.trim()) else {
215 continue;
216 };
217 // `@graph` 是常见的外层容器。
218 let candidates: Vec<&serde_json::Value> = match &value {
219 serde_json::Value::Array(items) => items.iter().collect(),
220 serde_json::Value::Object(map) => match map.get("@graph") {
221 Some(serde_json::Value::Array(items)) => items.iter().collect(),
222 _ => vec![&value],
223 },
224 _ => continue,
225 };
226 for node in candidates {
227 let title = json_ld_value(node, "headline").or_else(|| json_ld_value(node, "name"));
228 let description = json_ld_value(node, "description");
229 let image = json_ld_value(node, "image");
230 if title.is_some() || description.is_some() || image.is_some() {
231 return (title, description, image);
232 }
233 }
234 }
235 (None, None, None)
236}
237
238/// 把 `//host/path` 与 `/path` 补成绝对 URL。
239///
240/// 站点常常发协议相对地址(`//n.sinaimg.cn/...`),直接塞进 `<img src>` 会让
241/// 前端按当前页面解析,图片永远加载不出来。
242fn absolute_url(value: &str, base: &str) -> Option<String> {
243 let trimmed = value.trim();
244 if trimmed.is_empty() || trimmed.len() > 2048 {
245 return None;
246 }
247 if let Ok(parsed) = url::Url::parse(trimmed) {
248 return matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string());
249 }
250 let base = url::Url::parse(base).ok()?;
251 let joined = base.join(trimmed).ok()?;
252 matches!(joined.scheme(), "http" | "https").then(|| joined.to_string())
253}
254
97255fn decode_entities(value: &str) -> String {
98256 value
99257 .replace("&amp;", "&")
@@ -110,21 +268,49 @@
110268 description: Option<String>,
111269 image_url: Option<String>,
112270 site_name: Option<String>,
271 /// 跟随重定向后的落地地址。
272 final_url: Option<String>,
273 /// 页面自声明的 oEmbed 端点。
274 oembed: Option<String>,
275}
276
277/// 写进 `chat_link_unfurl` 的卡片字段。
278#[derive(Default)]
279struct CardMeta {
280 final_url: Option<String>,
281 oembed: Option<String>,
282 title: Option<String>,
283 description: Option<String>,
284 image_url: Option<String>,
285 site_name: Option<String>,
113286}
114287
288/// 抓取并解析卡片元数据,同时回报最终落地 URL 与页面自声明的 oEmbed 端点。
115289async fn fetch_meta(url: &str) -> Result<FetchedMeta, String> {
116 let text = fetch_text(url, "text/html", Some("text/html"), MAX_FETCH_BYTES).await?;
290 let fetched = fetch_following(url, "text/html", Some("text/html"), MAX_FETCH_BYTES).await?;
291 let text = fetched.body;
117292 let head = match text.find("</head>") {
118293 Some(idx) => &text[..idx],
119294 None => safe_prefix(&text, MAX_FETCH_BYTES),
120295 };
296 let (ld_title, ld_description, ld_image) = json_ld_meta(head);
297 let base = fetched.final_url.as_str();
121298
122299 Ok(FetchedMeta {
123 title: meta_content(head, "og:title").or_else(|| page_title(head)),
124 description: meta_content(head, "og:description")
125 .or_else(|| meta_content(head, "description")),
126 image_url: meta_content(head, "og:image"),
127 site_name: meta_content(head, "og:site_name"),
300 title: meta_any(head, &["og:title", "twitter:title"])
301 .or(ld_title)
302 .or_else(|| page_title(head)),
303 description: meta_any(
304 head,
305 &["og:description", "twitter:description", "description"],
306 )
307 .or(ld_description),
308 image_url: meta_any(head, &["og:image", "og:image:url", "twitter:image"])
309 .or(ld_image)
310 .and_then(|value| absolute_url(&value, base)),
311 site_name: meta_any(head, &["og:site_name", "twitter:site", "application-name"]),
312 final_url: Some(fetched.final_url.clone()),
313 oembed: link_oembed(head),
128314 })
129315}
130316
@@ -148,6 +334,20 @@
148334 })
149335}
150336
337/// 数据库行里已抓到的字段转成 [`social::Enrichment`]。
338fn row_enrichment(row: &link_unfurls::Model) -> Option<social::Enrichment> {
339 let enrichment = social::Enrichment {
340 title: row.title.clone(),
341 description: row.description.clone(),
342 image_url: row.image_url.clone(),
343 frame_src: None,
344 };
345 (enrichment.title.is_some()
346 || enrichment.description.is_some()
347 || enrichment.image_url.is_some())
348 .then_some(enrichment)
349}
350
151351impl ChatService {
152352 /// 带缓存的 OpenGraph 抓取:命中新鲜缓存直接返回,抓取失败记 Failed 并返回 `None`。
153353 async fn unfurl_row(&self, url: &str) -> Option<link_unfurls::Model> {
@@ -178,72 +378,68 @@
178378 return None;
179379 }
180380 };
181 if meta.title.is_none() && meta.description.is_none() && meta.image_url.is_none() {
381 // 自声明 oEmbed 的页面可能一个 OG 标签都没有,但仍然能出完整卡片。
382 if meta.title.is_none()
383 && meta.description.is_none()
384 && meta.image_url.is_none()
385 && meta.oembed.is_none()
386 {
182387 self.upsert_unfurl_row(&hash, url, None, now).await;
183388 return None;
184389 }
185390 self.upsert_unfurl_row(
186391 &hash,
187392 url,
188 Some((
189 meta.title.clone(),
190 meta.description.clone(),
191 meta.image_url.clone(),
192 meta.site_name.clone(),
193 )),
393 Some(CardMeta {
394 final_url: meta.final_url.clone(),
395 oembed: meta.oembed.clone(),
396 title: meta.title.clone(),
397 description: meta.description.clone(),
398 image_url: meta.image_url.clone(),
399 site_name: meta.site_name.clone(),
400 }),
194401 now,
195402 )
196403 .await
197404 }
198405
199 async fn resolve_unfurl(&self, url: &str) -> Option<serde_json::Value> {
200 let row = self.unfurl_row(url).await?;
201 Some(link_embed_json(url, &row))
202 }
203
204 /// 卡片类平台联网补全:优先平台自己声明的 oEmbed,否则复用 OpenGraph 抓取。
406 /// 卡片类平台联网补全:注册表 oEmbed → 页面自声明 oEmbed → OpenGraph。
205407 async fn enrich_card(&self, url: &str) -> Option<social::Enrichment> {
206408 if let Some(enrichment) = oembed::fetch(url).await {
207409 return Some(enrichment);
208410 }
209411 let row = self.unfurl_row(url).await?;
210 Some(social::Enrichment {
211 title: row.title,
212 description: row.description,
213 image_url: row.image_url,
214 frame_src: None,
215 })
412 if row_enrichment(&row).is_none()
413 && let Some(endpoint) = row.oembed.as_deref()
414 && let Some(from_oembed) = oembed::fetch_endpoint(endpoint).await
415 {
416 return Some(from_oembed);
417 }
418 row_enrichment(&row)
216419 }
217420
218421 async fn upsert_unfurl_row(
219422 &self,
220423 hash: &str,
221424 url: &str,
222 meta: Option<(
223 Option<String>,
224 Option<String>,
225 Option<String>,
226 Option<String>,
227 )>,
425 meta: Option<CardMeta>,
228426 now: OffsetDateTime,
229427 ) -> Option<link_unfurls::Model> {
230 let (status, title, description, image_url, site_name) = match &meta {
231 Some((t, d, i, s)) => (
232 UnfurlStatus::Ready,
233 t.clone(),
234 d.clone(),
235 i.clone(),
236 s.clone(),
237 ),
238 None => (UnfurlStatus::Failed, None, None, None, None),
428 let status = match &meta {
429 Some(_) => UnfurlStatus::Ready,
430 None => UnfurlStatus::Failed,
239431 };
432 let meta = meta.unwrap_or_default();
433 let url = url.chars().take(2048).collect::<String>();
240434 let row = link_unfurls::ActiveModel {
241435 url_hash: Set(hash.to_string()),
242 url: Set(url.chars().take(2048).collect()),
243 title: Set(title.clone()),
244 description: Set(description.clone()),
245 image_url: Set(image_url.clone()),
246 site_name: Set(site_name.clone()),
436 url: Set(url.clone()),
437 final_url: Set(meta.final_url.clone()),
438 oembed: Set(meta.oembed.clone()),
439 title: Set(meta.title.clone()),
440 description: Set(meta.description.clone()),
441 image_url: Set(meta.image_url.clone()),
442 site_name: Set(meta.site_name.clone()),
247443 status: Set(status),
248444 fetched_at: Set(now),
249445 };
@@ -256,11 +452,13 @@
256452 .ok()
257453 .flatten()?;
258454 let mut am: link_unfurls::ActiveModel = existing.into();
259 am.url = Set(url.chars().take(2048).collect());
260 am.title = Set(title);
261 am.description = Set(description);
262 am.image_url = Set(image_url);
263 am.site_name = Set(site_name);
455 am.url = Set(url);
456 am.final_url = Set(meta.final_url);
457 am.oembed = Set(meta.oembed);
458 am.title = Set(meta.title);
459 am.description = Set(meta.description);
460 am.image_url = Set(meta.image_url);
461 am.site_name = Set(meta.site_name);
264462 am.status = Set(status);
265463 am.fetched_at = Set(now);
266464 am.update(self.db().write()).await.ok()
@@ -275,24 +473,9 @@
275473 }
276474 let mut embeds = Vec::new();
277475 for url in urls {
278 match social::embed_json(&url) {
279 // 能直接内嵌的平台(iframe / 媒体)不联网。
280 Some(value) if value["kind"] != "link" => embeds.push(value),
281 // 只能出卡片的平台联网补全标题、简介和封面。
282 Some(value) => {
283 let merged = match self.enrich_card(&url).await {
284 Some(enrichment) => {
285 social::embed_json_enriched(&url, &enrichment).unwrap_or(value)
286 }
287 None => value,
288 };
289 embeds.push(merged);
290 }
291 None => {
292 if let Some(embed) = self.resolve_unfurl(&url).await {
293 embeds.push(embed);
294 }
295 }
476 match self.resolve_link(&url).await {
477 Some(value) => embeds.push(value),
478 None => {}
296479 }
297480 }
298481 if embeds.is_empty() {
@@ -342,6 +525,55 @@
342525 };
343526 self.realtime.publish_room(&event).await;
344527 }
528
529 /// 解析单条链接,返回可直接塞进 `MessageDto::embeds` 的一项。
530 ///
531 /// 顺序很关键:注册表里能直接内嵌的平台(YouTube / Spotify / B 站正片)不联网;
532 /// 其余先跟随重定向,落地页命中可嵌入平台就升级成播放器,否则退回卡片。
533 async fn resolve_link(&self, url: &str) -> Option<serde_json::Value> {
534 if let Some(value) = social::embed_json(url)
535 && value["kind"] != "link"
536 {
537 return Some(value);
538 }
539 // 卡片(含短链:`b23.tv` 也注册成了卡片)先跟随重定向拿到落地页。
540 let row = self.unfurl_row(url).await;
541 let final_url = row
542 .as_ref()
543 .and_then(|row| row.final_url.clone())
544 .filter(|final_url| !final_url.is_empty() && final_url != url);
545 if let Some(final_url) = final_url.as_deref() {
546 if let Some(value) = social::embed_json(final_url) {
547 let mut value = value;
548 if value["kind"] == "link" {
549 // 落地页的元数据已经在上面那次抓取里拿到了,别再联网。
550 value = match row.as_ref().and_then(row_enrichment) {
551 Some(enrichment) => {
552 social::embed_json_enriched(final_url, &enrichment).unwrap_or(value)
553 }
554 None => value,
555 };
556 }
557 // 前端按消息里的原始文本匹配 embed,锚点必须留在原 URL 上。
558 value["url"] = serde_json::Value::String(url.to_string());
559 return Some(value);
560 }
561 // 落地页也不在注册表里:直接用抓到的元数据出普通卡片。
562 if let Some(row) = row.as_ref() {
563 return Some(link_embed_json(url, row));
564 }
565 }
566 // 注册表认识的卡片平台。
567 if let Some(base) = social::embed_json(url) {
568 let merged = match self.enrich_card(url).await {
569 Some(enrichment) => social::embed_json_enriched(url, &enrichment).unwrap_or(base),
570 None => base,
571 };
572 return Some(merged);
573 }
574 // 注册表不认识、也拿不到落地页:退化成 OpenGraph 卡片,别把链接吞掉。
575 Some(link_embed_json(url, row.as_ref()?))
576 }
345577}
346578
347579pub fn spawn_unfurl(service: &ChatService, room_id: Uuid, message_id: Uuid, content: String) {
@@ -358,6 +590,7 @@
358590mod tests {
359591 use super::*;
360592 use crate::services::messages::MessageEmbedDto;
593 use crate::services::outbound::decode_body;
361594
362595 /// 平台 embed 必须能原样填进 `MessageDto::embeds`,否则聊天里收到的一直是
363596 /// 空数组 —— 这正是此前链接卡片链路断掉的位置。
@@ -390,6 +623,8 @@
390623 let row = link_unfurls::Model {
391624 url_hash: "hash".into(),
392625 url: "https://example.com/".into(),
626 final_url: None,
627 oembed: None,
393628 title: Some("Example".into()),
394629 description: Some("desc".into()),
395630 image_url: None,
@@ -430,4 +665,121 @@
430665 assert!(html.contains("商品简介"), "{html}");
431666 assert!(!html.contains("evil.example"), "{html}");
432667 }
668
669 /// 只发 `twitter:` 卡片、不发 `og:` 的站点以前会拿到空卡片。
670 #[test]
671 fn twitter_tags_back_up_missing_opengraph() {
672 let head = r#"<head>
673 <meta name="twitter:title" content="仅 Twitter 卡片">
674 <meta name="twitter:description" content="描述">
675 <meta name="twitter:image" content="https://cdn.example.com/cover.jpg">
676 </head>"#;
677 assert_eq!(
678 meta_any(head, &["og:title", "twitter:title"]).as_deref(),
679 Some("仅 Twitter 卡片")
680 );
681 assert_eq!(
682 meta_any(head, &["og:image", "og:image:url", "twitter:image"]).as_deref(),
683 Some("https://cdn.example.com/cover.jpg")
684 );
685 }
686
687 /// OpenGraph 缺失时的 JSON-LD 兜底,含 `@graph` 容器与对象形态的 `image`。
688 #[test]
689 fn json_ld_backs_up_missing_opengraph() {
690 let head = r#"<head><script type="application/ld+json">
691 {"@graph":[{"@type":"NewsArticle","headline":"结构化标题",
692 "description":"结构化摘要","image":{"url":"https://cdn.example.com/ld.jpg"}}]}
693 </script></head>"#;
694 let (title, description, image) = json_ld_meta(head);
695 assert_eq!(title.as_deref(), Some("结构化标题"));
696 assert_eq!(description.as_deref(), Some("结构化摘要"));
697 assert_eq!(image.as_deref(), Some("https://cdn.example.com/ld.jpg"));
698 }
699
700 /// 协议相对的封面地址必须补成绝对 URL,否则前端按聊天页域名解析、图片永远裂。
701 #[test]
702 fn protocol_relative_images_become_absolute() {
703 assert_eq!(
704 absolute_url("//n.sinaimg.cn/cover.jpg", "https://finance.sina.com.cn/a").as_deref(),
705 Some("https://n.sinaimg.cn/cover.jpg")
706 );
707 assert_eq!(
708 absolute_url("/img/cover.jpg", "https://example.com/post/1").as_deref(),
709 Some("https://example.com/img/cover.jpg")
710 );
711 assert!(absolute_url("javascript:alert(1)", "https://example.com/").is_none());
712 }
713
714 /// 页面自声明的 oEmbed 端点要能被认出来(带单双引号、大小写混排)。
715 #[test]
716 fn link_rel_oembed_is_discovered() {
717 let head = r#"<head><link rel="alternate" type="application/json+oembed"
718 href="https://example.com/oembed?url=x&amp;format=json"></head>"#;
719 assert_eq!(
720 link_oembed(head).as_deref(),
721 Some("https://example.com/oembed?url=x&format=json")
722 );
723 assert!(link_oembed("<head><link rel='stylesheet' href='/a.css'></head>").is_none());
724 assert!(
725 link_oembed("<head><link rel='alternate' type='text/xml+oembed' href='/o'></head>")
726 .is_none()
727 );
728 }
729
730 /// GBK 中文站按 `content-type` 解码,不能出乱码。
731 #[test]
732 fn gbk_pages_decode_from_the_declared_charset() {
733 let (encoded, _, _) = encoding_rs::GBK.encode("中文标题");
734 let text = decode_body(&encoded, "text/html; charset=gbk");
735 assert_eq!(text, "中文标题");
736 let text = decode_body(&encoded, "text/html; charset=GB2312");
737 assert_eq!(text, "中文标题");
738 }
739
740 /// 没声明 charset 时 UTF-8 直通;非法 UTF-8 回退 GB18030 而不是替换成 U+FFFD。
741 #[test]
742 fn undeclared_charsets_fall_back_to_utf8_then_gb18030() {
743 assert_eq!(decode_body("标题".as_bytes(), "text/html"), "标题");
744 let (encoded, _, _) = encoding_rs::GB18030.encode("中文标题");
745 assert_eq!(decode_body(&encoded, "text/html"), "中文标题");
746 }
747
748 /// Wikipedia 这类带括号的路径不能被当成句尾括号裁掉。
749 #[test]
750 fn urls_keep_balanced_parentheses() {
751 let urls = extract_urls(
752 "看看 https://en.wikipedia.org/wiki/Rust_(programming_language) 这个",
753 );
754 assert_eq!(
755 urls,
756 vec!["https://en.wikipedia.org/wiki/Rust_(programming_language)".to_string()]
757 );
758 }
759
760 /// 被中文括号或引号包起来的链接要能剥壳;末尾多余标点要去掉。
761 #[test]
762 fn urls_are_unwrapped_from_surrounding_punctuation() {
763 assert_eq!(
764 extract_urls("(详见 https://example.com/a/b)"),
765 vec!["https://example.com/a/b".to_string()]
766 );
767 assert_eq!(
768 extract_urls("见 https://example.com/a/b."),
769 vec!["https://example.com/a/b".to_string()]
770 );
771 assert_eq!(
772 extract_urls("「https://example.com/x」"),
773 vec!["https://example.com/x".to_string()]
774 );
775 }
776
777 /// 包裹性括号要剥掉,但属于路径的括号要留住。
778 #[test]
779 fn wrapper_parenthesis_is_dropped_but_path_parenthesis_survives() {
780 assert_eq!(
781 extract_urls("(https://en.wikipedia.org/wiki/Rust_(programming_language))"),
782 vec!["https://en.wikipedia.org/wiki/Rust_(programming_language)".to_string()]
783 );
784 }
433785}
Modified lib/chat/Cargo.toml View lib/chat/Cargo.toml
@@ -22,6 +22,8 @@
2222livekit-protocol = { workspace = true }
2323serde = { workspace = true, features = ["derive"] }
2424serde_json = { workspace = true }
25encoding_rs = { workspace = true }
26url = { workspace = true }
2527utoipa = { workspace = true }
2628uuid = { workspace = true }
2729time = { workspace = true }
Modified lib/models/src/chat/link_unfurls.rs View lib/models/src/chat/link_unfurls.rs
@@ -33,10 +33,14 @@
3333 #[sea_orm(primary_key, auto_increment = false)]
3434 pub url_hash: String,
3535 pub url: String,
36 /// 跟随重定向后的落地地址;短链靠它才能在缓存命中时仍认出真实平台。
37 pub final_url: Option<String>,
3638 pub title: Option<String>,
3739 pub description: Option<String>,
3840 pub image_url: Option<String>,
3941 pub site_name: Option<String>,
42 /// 页面自声明的 oEmbed 端点,跨请求复用免得每次重新解析 HTML。
43 pub oembed: Option<String>,
4044 #[sea_orm(default_value = 0)]
4145 pub status: UnfurlStatus,
4246 pub fetched_at: OffsetDateTime,
Modified Cargo.lock View Cargo.lock
@@ -493,6 +493,19 @@
493493checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a"
494494
495495[[package]]
496name = "async-compression"
497version = "0.4.36"
498source = "registry+https://github.com/rust-lang/crates.io-index"
499checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37"
500dependencies = [
501 "compression-codecs",
502 "compression-core",
503 "futures-core",
504 "pin-project-lite",
505 "tokio",
506]
507
508[[package]]
496509name = "async-graphql"
497510version = "7.2.1"
498511source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1258,6 +1271,7 @@
12581271 "base64 0.23.1",
12591272 "bytes",
12601273 "config",
1274 "encoding_rs",
12611275 "export",
12621276 "futures",
12631277 "hmac 0.13.0",
@@ -1282,6 +1296,7 @@
12821296 "tokio",
12831297 "tokio-util",
12841298 "tracing",
1299 "url 2.5.8",
12851300 "utoipa",
12861301 "uuid",
12871302]
@@ -1425,6 +1440,24 @@
14251440]
14261441
14271442[[package]]
1443name = "compression-codecs"
1444version = "0.4.38"
1445source = "registry+https://github.com/rust-lang/crates.io-index"
1446checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
1447dependencies = [
1448 "brotli",
1449 "compression-core",
1450 "flate2",
1451 "memchr",
1452]
1453
1454[[package]]
1455name = "compression-core"
1456version = "0.4.32"
1457source = "registry+https://github.com/rust-lang/crates.io-index"
1458checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
1459
1460[[package]]
14281461name = "concurrent-queue"
14291462version = "2.5.0"
14301463source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9720,12 +9753,17 @@
97209753source = "registry+https://github.com/rust-lang/crates.io-index"
97219754checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
97229755dependencies = [
9756 "async-compression",
97239757 "bitflags 2.13.1",
97249758 "bytes",
9759 "futures-core",
97259760 "futures-util",
97269761 "http 1.5.0",
97279762 "http-body 1.1.0",
9763 "http-body-util",
97289764 "pin-project-lite",
9765 "tokio",
9766 "tokio-util",
97299767 "tower",
97309768 "tower-layer",
97319769 "tower-service",
Modified Cargo.toml View Cargo.toml
@@ -95,7 +95,7 @@
9595maud = { version = "0.27", features = ["axum"] }
9696pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
9797lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls", "ring", "rustls-native-certs", "builder"] }
98reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
98reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "brotli", "deflate"] }
9999livekit-api = { version = "0.7.1", default-features = false, features = ["services", "webhooks", "rustls-tls-webpki-roots"] }
100100livekit-protocol = "0.7.13"
101101anyhow = "1"
@@ -119,3 +119,4 @@
119119regex = "1"
120120url = "2"
121121percent-encoding = "2"
122encoding_rs = "0.8"