Skip to main content

joaju_unfurl/
lib.rs

1//! # joaju-unfurl
2//!
3//! A fast and secure Rust library for OpenGraph metadata extraction
4//! with SSRF defensive protection and visual preview card rendering for Typst.
5//!
6//! > ⚠️ **Early Development Notice (pre-1.0 / alpha)**: This library is currently in early
7//! > active development (`0.1.0`). It is NOT recommended for production use beyond experimental
8//! > testing and research. APIs are subject to breaking changes prior to a stable 1.0.0 release.
9//!
10//! ## Quick Example
11//!
12//! ```rust
13//! use joaju_unfurl::extract_metadata;
14//!
15//! let html = r#"<title>Example</title><meta property="og:description" content="Desc">"#;
16//! let meta = extract_metadata(html, "https://example.org").unwrap();
17//! assert_eq!(meta.title.as_deref(), Some("Example"));
18//! assert_eq!(meta.domain, "example.org");
19//! ```
20
21use 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}