Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 246 additions & 1 deletion desktop/src-tauri/src/commands/link_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,192 @@ use url::Url;

const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024;
const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4);
const MAX_METADATA_REDIRECTS: usize = 3;

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkPreviewMetadata {
pub href: String,
pub site_name: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub image_url: Option<String>,
pub favicon_url: Option<String>,
}

/// Fetch generic OpenGraph metadata (title/description/image/site name) for an
/// arbitrary http(s) URL. Returns `None` when the page yields no usable title.
#[tauri::command]
pub async fn fetch_link_preview_metadata(
href: String,
) -> Result<Option<LinkPreviewMetadata>, String> {
let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?;
if url.scheme() != "https" && url.scheme() != "http" {
return Ok(None);
}
if url.host_str().is_none() {
return Ok(None);
}

let client = reqwest::Client::builder()
.redirect(Policy::limited(MAX_METADATA_REDIRECTS))
.pool_idle_timeout(Duration::from_secs(10))
.pool_max_idle_per_host(1)
.build()
.map_err(|error| format!("link preview metadata client failed: {error}"))?;

let request = client
.get(url.as_str())
.header(
ACCEPT,
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header(USER_AGENT, "Buzz Desktop link preview");

let response = tokio::time::timeout(TITLE_FETCH_TIMEOUT, request.send())
.await
.map_err(|_| "link preview metadata request timed out".to_string())?
.map_err(|error| format!("link preview metadata request failed: {error}"))?;

if !response.status().is_success() {
return Ok(None);
}

let is_html = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_ascii_lowercase().contains("text/html"))
.unwrap_or(true);
if !is_html {
return Ok(None);
}

let final_url = response.url().clone();
let body = read_limited_text(response).await?;
Ok(extract_link_preview_metadata(&final_url, &body))
}

fn extract_link_preview_metadata(url: &Url, html: &str) -> Option<LinkPreviewMetadata> {
let title = meta_content(html, &["og:title", "twitter:title"])
.or_else(|| extract_title_tag(html))
.map(|title| normalize_meta_text(&title, 180))
.filter(|title| !title.is_empty())?;

let description = meta_content(
html,
&["og:description", "twitter:description", "description"],
)
.map(|value| normalize_meta_text(&value, 300))
.filter(|value| !value.is_empty());

let site_name = meta_content(html, &["og:site_name"])
.map(|value| normalize_meta_text(&value, 80))
.filter(|value| !value.is_empty());

let image_url = meta_content(html, &["og:image", "twitter:image"])
.and_then(|value| resolve_href(url, value.trim()));

let favicon_url = extract_favicon_href(html)
.and_then(|value| resolve_href(url, value.trim()))
.or_else(|| resolve_href(url, "/favicon.ico"));

Some(LinkPreviewMetadata {
href: url.to_string(),
site_name,
title: Some(title),
description,
image_url,
favicon_url,
})
}

/// Return the `content` of the first `<meta>` tag matching any of `names`
/// (checked against both `property` and `name` attributes), in `names` order.
fn meta_content(html: &str, names: &[&str]) -> Option<String> {
for name in names {
let lower = html.to_ascii_lowercase();
let mut search_from = 0;

while let Some(relative_start) = lower[search_from..].find("<meta") {
let start = search_from + relative_start;
let Some(relative_end) = lower[start..].find('>') else {
break;
};
let end = start + relative_end + 1;
let tag = &html[start..end];

let matches_name = attr_value(tag, "property")
.or_else(|| attr_value(tag, "name"))
.map(|value| value.eq_ignore_ascii_case(name))
.unwrap_or(false);

if matches_name {
if let Some(content) = attr_value(tag, "content") {
if !content.trim().is_empty() {
return Some(content);
}
}
}

search_from = end;
}
}

None
}

fn extract_favicon_href(html: &str) -> Option<String> {
let lower = html.to_ascii_lowercase();
let mut search_from = 0;

while let Some(relative_start) = lower[search_from..].find("<link") {
let start = search_from + relative_start;
let Some(relative_end) = lower[start..].find('>') else {
break;
};
let end = start + relative_end + 1;
let tag = &html[start..end];

let is_icon = attr_value(tag, "rel")
.map(|rel| {
rel.to_ascii_lowercase().split_whitespace().any(|token| {
token == "icon" || token == "shortcut" || token == "apple-touch-icon"
})
})
.unwrap_or(false);

if is_icon {
if let Some(href) = attr_value(tag, "href") {
if !href.trim().is_empty() {
return Some(href);
}
}
}

search_from = end;
}

None
}

fn resolve_href(base: &Url, href: &str) -> Option<String> {
let resolved = base.join(href).ok()?;
if resolved.scheme() != "https" && resolved.scheme() != "http" {
return None;
}
Some(resolved.to_string())
}

fn normalize_meta_text(raw: &str, max_chars: usize) -> String {
decode_html_entities(raw)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(max_chars)
.collect()
}

#[tauri::command]
pub async fn fetch_link_preview_title(href: String) -> Result<Option<String>, String> {
Expand Down Expand Up @@ -251,9 +437,68 @@ fn decode_html_entities(value: &str) -> String {

#[cfg(test)]
mod tests {
use super::{extract_google_title, is_supported_google_link};
use super::{extract_google_title, extract_link_preview_metadata, is_supported_google_link};
use url::Url;

#[test]
fn metadata_extracts_open_graph_fields() {
let html = r#"
<html>
<head>
<meta property="og:site_name" content="Example News">
<meta property="og:title" content="Big &amp; important story">
<meta property="og:description" content=" A story about things. ">
<meta property="og:image" content="/images/story.png">
<link rel="icon" href="/favicon.svg">
<title>Fallback title</title>
</head>
</html>
"#;
let url = Url::parse("https://news.example.com/story/1").unwrap();

let metadata = extract_link_preview_metadata(&url, html).unwrap();
assert_eq!(metadata.site_name.as_deref(), Some("Example News"));
assert_eq!(metadata.title.as_deref(), Some("Big & important story"));
assert_eq!(
metadata.description.as_deref(),
Some("A story about things.")
);
assert_eq!(
metadata.image_url.as_deref(),
Some("https://news.example.com/images/story.png")
);
assert_eq!(
metadata.favicon_url.as_deref(),
Some("https://news.example.com/favicon.svg")
);
}

#[test]
fn metadata_falls_back_to_title_tag_and_default_favicon() {
let html = "<html><head><title>Plain page</title></head></html>";
let url = Url::parse("https://example.com/page").unwrap();

let metadata = extract_link_preview_metadata(&url, html).unwrap();
assert_eq!(metadata.title.as_deref(), Some("Plain page"));
assert_eq!(metadata.site_name, None);
assert_eq!(metadata.description, None);
assert_eq!(metadata.image_url, None);
assert_eq!(
metadata.favicon_url.as_deref(),
Some("https://example.com/favicon.ico")
);
}

#[test]
fn metadata_requires_a_title() {
let url = Url::parse("https://example.com/").unwrap();
assert_eq!(extract_link_preview_metadata(&url, "<html></html>"), None);
assert_eq!(
extract_link_preview_metadata(&url, "<title> </title>"),
None
);
}

#[test]
fn title_prefers_open_graph_title() {
let html = r#"
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ pub fn run() {
get_relay_ws_url,
get_relay_http_url,
get_media_proxy_port,
fetch_link_preview_metadata,
fetch_link_preview_title,
discover_acp_auth_methods,
discover_acp_providers,
Expand Down
4 changes: 3 additions & 1 deletion desktop/src-tauri/src/linux_media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,19 @@
//! which is the backend WebKitGTK media capture is reliable on.

/// The origin Tauri serves the packaged app from on Linux.
#[cfg(any(target_os = "linux", test))]
const PROD_ORIGIN: &str = "tauri://localhost";

/// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort`
/// 1420 in `vite.config.ts`). Only trusted in debug builds.
#[cfg(debug_assertions)]
#[cfg(all(debug_assertions, any(target_os = "linux", test)))]
const DEV_ORIGIN: &str = "http://localhost:1420";

/// Whether `uri` (the webview's current document URI) is a trusted app origin
/// allowed to use mic/camera. Matches the origin exactly or as a path prefix so
/// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip
/// through. Pure and platform-independent so it can be unit-tested everywhere.
#[cfg(any(target_os = "linux", test))]
fn is_trusted_media_origin(uri: &str) -> bool {
fn matches(uri: &str, origin: &str) -> bool {
uri == origin
Expand Down
59 changes: 59 additions & 0 deletions desktop/src/shared/lib/linkPreview.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
extractGenericLinkPreviewUrls,
extractSupportedLinkPreviews,
isSupportedLinkAutolinkLabel,
parseSupportedLinkPreview,
Expand Down Expand Up @@ -252,3 +253,61 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
);
assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
});

test("extractGenericLinkPreviewUrls extracts non-provider https URLs", () => {
assert.deepEqual(
extractGenericLinkPreviewUrls(
"Check https://example.com/article and https://blog.rust-lang.org/2024/post/",
),
["https://example.com/article", "https://blog.rust-lang.org/2024/post/"],
);
});

test("extractGenericLinkPreviewUrls skips provider URLs covered by compact cards", () => {
assert.deepEqual(
extractGenericLinkPreviewUrls(
"See https://github.com/block/sprout/pull/1 and https://example.com/x",
),
["https://example.com/x"],
);
});

test("extractGenericLinkPreviewUrls skips media URLs", () => {
assert.deepEqual(
extractGenericLinkPreviewUrls(
"https://example.com/pic.png https://example.com/clip.mp4?t=1 https://example.com/page",
),
["https://example.com/page"],
);
});

test("extractGenericLinkPreviewUrls skips code blocks and dedupes", () => {
assert.deepEqual(
extractGenericLinkPreviewUrls(
[
"`https://example.com/inline-code`",
"```",
"https://example.com/fenced",
"```",
"https://example.com/page https://example.com/page",
].join("\n"),
),
["https://example.com/page"],
);
});

test("extractGenericLinkPreviewUrls trims trailing punctuation and caps at three", () => {
assert.deepEqual(
extractGenericLinkPreviewUrls(
"https://a.example/1. https://b.example/2, https://c.example/3! https://d.example/4",
),
["https://a.example/1", "https://b.example/2", "https://c.example/3"],
);
});

test("extractGenericLinkPreviewUrls extracts markdown link targets", () => {
assert.deepEqual(
extractGenericLinkPreviewUrls("[the docs](https://example.com/docs)"),
["https://example.com/docs"],
);
});
Loading
Loading