1use reqwest::Url;
22use scraper::{Html, Selector};
23use serde::{Deserialize, Serialize};
24use std::time::Duration;
25use thiserror::Error;
26
27#[derive(Error, Debug)]
28pub enum UnfurlError {
29 #[error("Invalid or missing URL: {0}")]
30 InvalidUrl(String),
31
32 #[error("Access blocked for security reasons (SSRF): {0}")]
33 SsrfBlocked(String),
34
35 #[error("HTTP request error: {0}")]
36 Http(#[from] reqwest::Error),
37
38 #[error("Parsing error")]
39 ParseError,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ExtractedMetadata {
44 pub domain: String,
45 pub site_name: Option<String>,
46 pub title: Option<String>,
47 pub description: Option<String>,
48 pub image_url: Option<String>,
49 pub favicon_url: Option<String>,
50 pub author: Option<String>,
51 pub date: Option<String>,
52 pub reading_time_min: Option<u32>,
53 pub has_article: bool,
54}
55
56#[derive(Debug, Deserialize)]
57pub struct UnfurlRequest {
58 pub url: Option<String>,
59 #[serde(default)]
60 pub archive: bool,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct UnfurlResponse {
65 pub ok: bool,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub error: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub url: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub domain: Option<String>,
72 #[serde(rename = "siteName", skip_serializing_if = "Option::is_none")]
73 pub site_name: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub title: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub description: Option<String>,
78 #[serde(rename = "imageUrl", skip_serializing_if = "Option::is_none")]
79 pub image_url: Option<String>,
80 #[serde(rename = "faviconUrl", skip_serializing_if = "Option::is_none")]
81 pub favicon_url: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub author: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub date: Option<String>,
86 #[serde(rename = "readingTimeMin", skip_serializing_if = "Option::is_none")]
87 pub reading_time_min: Option<u32>,
88 #[serde(rename = "hasArticle", skip_serializing_if = "Option::is_none")]
89 pub has_article: Option<bool>,
90 #[serde(rename = "pdfBase64", skip_serializing_if = "Option::is_none")]
91 pub pdf_base64: Option<String>,
92}
93
94pub fn is_ssrf_risk(url_str: &str) -> bool {
95 let parsed = match Url::parse(url_str) {
96 Ok(u) => u,
97 Err(_) => return true,
98 };
99
100 let scheme = parsed.scheme();
101 if scheme != "http" && scheme != "https" {
102 return true;
103 }
104
105 if let Some(host) = parsed.host_str() {
106 let host_lower = host.to_lowercase();
107 if host_lower == "localhost"
108 || host_lower == "127.0.0.1"
109 || host_lower == "::1"
110 || host_lower == "[::1]"
111 || host_lower == "0.0.0.0"
112 || host_lower.starts_with("192.168.")
113 || host_lower.starts_with("10.")
114 || host_lower.ends_with(".local")
115 {
116 return true;
117 }
118
119 if host_lower.starts_with("172.") {
120 let parts: Vec<&str> = host_lower.split('.').collect();
121 if parts.len() >= 2 {
122 if let Ok(second_octet) = parts[1].parse::<u8>() {
123 if (16..=31).contains(&second_octet) {
124 return true;
125 }
126 }
127 }
128 }
129 } else {
130 return true;
131 }
132
133 false
134}
135
136pub fn extract_metadata(html: &str, base_url: &str) -> Result<ExtractedMetadata, UnfurlError> {
137 let parsed_url =
138 Url::parse(base_url).map_err(|_| UnfurlError::InvalidUrl(base_url.to_string()))?;
139 let domain = parsed_url.host_str().unwrap_or("").to_string();
140
141 let document = Html::parse_document(html);
142
143 let get_meta = |property: &str, name: &str| -> Option<String> {
144 let sel_prop = Selector::parse(&format!("meta[property='{}']", property)).ok()?;
145 if let Some(el) = document.select(&sel_prop).next() {
146 if let Some(content) = el.value().attr("content") {
147 if !content.trim().is_empty() {
148 return Some(content.trim().to_string());
149 }
150 }
151 }
152
153 let sel_name = Selector::parse(&format!("meta[name='{}']", name)).ok()?;
154 if let Some(el) = document.select(&sel_name).next() {
155 if let Some(content) = el.value().attr("content") {
156 if !content.trim().is_empty() {
157 return Some(content.trim().to_string());
158 }
159 }
160 }
161
162 None
163 };
164
165 let title = get_meta("og:title", "twitter:title").or_else(|| {
166 let sel = Selector::parse("title").ok()?;
167 document
168 .select(&sel)
169 .next()
170 .map(|el| el.text().collect::<String>().trim().to_string())
171 .filter(|s| !s.is_empty())
172 });
173
174 let description = get_meta("og:description", "description");
175 let site_name = get_meta("og:site_name", "application-name");
176 let author =
177 get_meta("article:author", "author").or_else(|| get_meta("og:article:author", "author"));
178 let date = get_meta("article:published_time", "date");
179
180 let image_url = get_meta("og:image", "twitter:image")
181 .and_then(|img| parsed_url.join(&img).ok().map(|u| u.to_string()));
182
183 let favicon_url = Selector::parse("link[rel*='icon']").ok().and_then(|sel| {
184 document
185 .select(&sel)
186 .next()
187 .and_then(|el| el.value().attr("href"))
188 .and_then(|href| parsed_url.join(href).ok().map(|u| u.to_string()))
189 });
190
191 let sel_p = Selector::parse("p").ok();
192 let text_content = sel_p
193 .map(|sel| {
194 document
195 .select(&sel)
196 .map(|el| el.text().collect::<String>())
197 .collect::<Vec<_>>()
198 .join(" ")
199 })
200 .unwrap_or_default();
201
202 let word_count = text_content.split_whitespace().count();
203 let reading_time_min = if word_count > 0 {
204 Some(((word_count as f64 / 200.0).ceil() as u32).max(1))
205 } else {
206 None
207 };
208
209 let has_article = Selector::parse("article")
210 .ok()
211 .map(|sel| document.select(&sel).next().is_some())
212 .unwrap_or(false);
213
214 Ok(ExtractedMetadata {
215 domain,
216 site_name,
217 title,
218 description,
219 image_url,
220 favicon_url,
221 author,
222 date,
223 reading_time_min,
224 has_article,
225 })
226}
227
228pub async fn fetch_and_unfurl(url_str: &str) -> Result<ExtractedMetadata, UnfurlError> {
229 if is_ssrf_risk(url_str) {
230 return Err(UnfurlError::SsrfBlocked(url_str.to_string()));
231 }
232
233 let client = reqwest::Client::builder()
234 .timeout(Duration::from_secs(10))
235 .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
236 .build()?;
237
238 let resp = client.get(url_str).send().await?;
239 let html = resp.text().await?;
240
241 extract_metadata(&html, url_str)
242}
243
244pub fn render_unfurl_typst_card(meta: &ExtractedMetadata, original_url: &str) -> String {
245 let title = meta.title.as_deref().unwrap_or("Sem título");
246 let desc = meta.description.as_deref().unwrap_or("");
247 let domain = &meta.domain;
248
249 format!(
250 "#rect(width: 100%, stroke: 0.5pt + luma(180), radius: 6pt, inset: 10pt)[\n #link(\"{}\")[\n #text(weight: \"bold\", size: 10.5pt)[{}]\n #v(-4pt)\n #text(fill: luma(100), size: 8.5pt)[{}]\n #v(-2pt)\n #text(size: 9pt)[{}]\n ]\n]",
251 original_url, title, domain, desc
252 )
253}