diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5cc9c01d33..3ebf7fe601 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -1,116 +1,420 @@ -use std::time::Duration; +use std::{io::Cursor, net::IpAddr, time::Duration}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use image::ImageDecoder; use futures_util::StreamExt; use reqwest::{ - header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, + header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT}, redirect::Policy, }; +use serde::Serialize; use url::Url; -const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024; -const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4); +const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024; +const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024; +const MAX_IMAGE_DIMENSION: u32 = 4096; +const MAX_IMAGE_PIXELS: u64 = 16_000_000; +const MAX_SANITIZED_DIMENSION: u32 = 1200; +const PREVIEW_FETCH_TIMEOUT: Duration = Duration::from_secs(4); +const PREVIEW_TOTAL_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_REDIRECTS: usize = 3; +const MAX_METADATA_CHARS: usize = 180; + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LinkPreviewMetadata { + title: String, + site_name: Option, + description: Option, + image_data_url: Option, + image_domain: Option, + favicon_data_url: Option, +} #[tauri::command] -pub async fn fetch_link_preview_title(href: String) -> Result, String> { - let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; - if !is_supported_google_link(&url) { - return Ok(None); +pub async fn fetch_link_preview_metadata( + href: String, +) -> Result, String> { + tokio::time::timeout( + PREVIEW_TOTAL_TIMEOUT, + fetch_link_preview_metadata_inner(href), + ) + .await + .map_err(|_| "link preview request timed out".to_string())? +} + +async fn fetch_link_preview_metadata_inner( + href: String, +) -> Result, String> { + let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; + validate_public_https_url(&url).await?; + + for redirect_count in 0..=MAX_REDIRECTS { + let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; + + if response.status().is_redirection() { + if redirect_count == MAX_REDIRECTS { + return Ok(None); + } + let Some(location) = response.headers().get(LOCATION) else { + return Ok(None); + }; + let location = location + .to_str() + .map_err(|_| "link preview redirect has an invalid location".to_string())?; + url = url + .join(location) + .map_err(|error| format!("invalid link preview redirect: {error}"))?; + validate_public_https_url(&url).await?; + continue; + } + + if !response.status().is_success() || !is_html_response(&response) { + return Ok(None); + } + let body = read_bytes_prefix(response, MAX_PREVIEW_FETCH_BYTES).await?; + let body = String::from_utf8_lossy(&body); + let Some(mut metadata) = extract_link_preview_metadata(&body) else { + return Ok(None); + }; + if let Some(image_url) = extract_image_url(&body, &url) { + if let Ok((data_url, domain)) = fetch_sanitized_image(image_url, false).await { + metadata.image_data_url = Some(data_url); + metadata.image_domain = Some(domain); + } + } + if let Some(favicon_url) = extract_favicon_url(&body, &url) { + if let Ok((data_url, _)) = fetch_sanitized_image(favicon_url, true).await { + metadata.favicon_data_url = Some(data_url); + } + } + return Ok(Some(metadata)); } + Ok(None) +} + +async fn validate_public_https_url(url: &Url) -> Result<(), String> { + if url.scheme() != "https" || url.username() != "" || url.password().is_some() { + return Err("link previews require an HTTPS URL without credentials".to_string()); + } + if url.port().is_some_and(|port| port != 443) { + return Err("link previews require the default HTTPS port".to_string()); + } + + let host = url + .host_str() + .ok_or_else(|| "link preview URL has no host".to_string())?; + resolve_public_addresses(host).await.map(|_| ()) +} + +async fn resolve_public_addresses(host: &str) -> Result, String> { + let host = host.to_string(); + let addresses = tokio::net::lookup_host((host.as_str(), 443)) + .await + .map_err(|error| format!("link preview DNS resolution failed: {error}"))? + .map(|address| address.ip()) + .collect::>(); + + if addresses.is_empty() { + return Err("link preview DNS resolution returned no addresses".to_string()); + } + if addresses.iter().any(buzz_core_pkg::network::is_private_ip) { + return Err("link preview host resolved to a private or reserved address".to_string()); + } + + Ok(addresses) +} + +async fn send_pinned_request(url: &Url, accept: &str) -> Result { + let host = url + .host_str() + .ok_or_else(|| "link preview URL has no host".to_string())?; + let addresses = resolve_public_addresses(host).await?; + let socket_addresses = addresses + .into_iter() + .map(|address| std::net::SocketAddr::new(address, 443)) + .collect::>(); let client = reqwest::Client::builder() + .no_proxy() .redirect(Policy::none()) - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) + .pool_max_idle_per_host(0) + .resolve_to_addrs(host, &socket_addresses) .build() - .map_err(|error| format!("link preview title client failed: {error}"))?; - + .map_err(|error| format!("link preview 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(ACCEPT, accept) .header(USER_AGENT, "Buzz Desktop link preview"); - let response = tokio::time::timeout(TITLE_FETCH_TIMEOUT, request.send()) + tokio::time::timeout(PREVIEW_FETCH_TIMEOUT, request.send()) .await - .map_err(|_| "link preview title request timed out".to_string())? - .map_err(|error| format!("link preview title request failed: {error}"))?; - - if !response.status().is_success() { - return Ok(None); - } + .map_err(|_| "link preview request timed out".to_string())? + .map_err(|error| format!("link preview request failed: {error}")) +} - let is_html = response +fn is_html_response(response: &reqwest::Response) -> bool { + 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 body = read_limited_text(response).await?; - Ok(extract_google_title(&body)) + .map(|value| { + let mime = value.split(';').next().unwrap_or_default().trim(); + mime.eq_ignore_ascii_case("text/html") + || mime.eq_ignore_ascii_case("application/xhtml+xml") + }) + .unwrap_or(false) } -fn is_supported_google_link(url: &Url) -> bool { - if url.scheme() != "https" { - return false; - } +async fn read_bytes_prefix(response: reqwest::Response, limit: usize) -> Result, String> { + let mut stream = response.bytes_stream(); + let mut bytes = Vec::with_capacity(limit); - let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { - return false; - }; - let segments = url - .path_segments() - .map(|segments| segments.collect::>()) - .unwrap_or_default(); - - match host.trim_start_matches("www.") { - "docs.google.com" => { - matches!( - segments.as_slice(), - ["document", "d", _, ..] - | ["spreadsheets", "d", _, ..] - | ["presentation", "d", _, ..] - ) - } - "drive.google.com" => { - matches!(segments.as_slice(), ["file", "d", _, ..]) - || matches!(segments.as_slice(), ["drive", "folders", _, ..]) - || (segments.first() == Some(&"open") - && url.query_pairs().any(|(key, _)| key == "id")) - } - _ => false, + while bytes.len() < limit { + let Some(chunk) = stream.next().await else { + break; + }; + let chunk = chunk.map_err(|error| format!("reading link preview failed: {error}"))?; + let remaining = limit - bytes.len(); + bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]); } + Ok(bytes) } -async fn read_limited_text(response: reqwest::Response) -> Result { +async fn read_limited_bytes(response: reqwest::Response, limit: usize) -> Result, String> { let mut stream = response.bytes_stream(); let mut bytes = Vec::new(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| format!("reading title response failed: {error}"))?; - if bytes.len() + chunk.len() > MAX_TITLE_FETCH_BYTES { - let remaining = MAX_TITLE_FETCH_BYTES.saturating_sub(bytes.len()); - bytes.extend_from_slice(&chunk[..remaining]); - break; + let chunk = chunk.map_err(|error| format!("reading link preview failed: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > limit { + return Err("link preview response exceeded the size limit".to_string()); } bytes.extend_from_slice(&chunk); } + Ok(bytes) +} + +fn extract_favicon_url(html: &str, page_url: &Url) -> Option { + let lower = html.to_ascii_lowercase(); + let mut search_from = 0; + let mut fallback = None; + + while let Some(relative_start) = lower[search_from..].find("') else { + break; + }; + let end = start + relative_end + 1; + let tag = &html[start..end]; + let rel = attr_value(tag, "rel"); + let is_icon = rel.as_ref().is_some_and(|value| { + value.split_ascii_whitespace().any(|token| { + token.eq_ignore_ascii_case("icon") || token.eq_ignore_ascii_case("apple-touch-icon") + }) + }); + if is_icon { + if let Some(href) = attr_value(tag, "href") { + if let Ok(url) = page_url.join(href.trim()) { + let declared_type = attr_value(tag, "type"); + let is_supported_raster = declared_type.as_ref().is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "image/jpeg" | "image/png" | "image/webp" + ) + }) || matches!( + url.path() + .rsplit_once('.') + .map(|(_, extension)| extension.to_ascii_lowercase()) + .as_deref(), + Some("jpg" | "jpeg" | "png" | "webp") + ); + if is_supported_raster { + return Some(url); + } + fallback.get_or_insert(url); + } + } + } + search_from = end; + } + + fallback +} + +fn extract_image_url(html: &str, page_url: &Url) -> Option { + let raw = extract_meta_content(html, "property", "og:image") + .or_else(|| extract_meta_content(html, "property", "og:image:secure_url")) + .or_else(|| extract_meta_content(html, "name", "twitter:image"))?; + page_url.join(raw.trim()).ok() +} + +async fn fetch_sanitized_image( + mut url: Url, + preserve_transparency: bool, +) -> Result<(String, String), String> { + validate_public_https_url(&url).await?; + for redirect_count in 0..=MAX_REDIRECTS { + let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp").await?; + if response.status().is_redirection() { + if redirect_count == MAX_REDIRECTS { + return Err("link preview image redirected too many times".to_string()); + } + let location = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "link preview image redirect has an invalid location".to_string())?; + url = url + .join(location) + .map_err(|error| format!("invalid link preview image redirect: {error}"))?; + validate_public_https_url(&url).await?; + continue; + } + if !response.status().is_success() { + return Err("link preview image request was unsuccessful".to_string()); + } + let declared_mime = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + }) + .ok_or_else(|| "link preview image has no content type".to_string())?; + if !matches!( + declared_mime.as_str(), + "image/jpeg" | "image/png" | "image/webp" + ) { + return Err("link preview image type is unsupported".to_string()); + } + if response + .content_length() + .is_some_and(|size| size > MAX_IMAGE_FETCH_BYTES as u64) + { + return Err("link preview image exceeded the size limit".to_string()); + } + let bytes = read_limited_bytes(response, MAX_IMAGE_FETCH_BYTES).await?; + let data_url = tokio::task::spawn_blocking(move || { + sanitize_image(&bytes, &declared_mime, preserve_transparency) + }) + .await + .map_err(|_| "link preview image sanitizer failed".to_string())??; + let domain = url.host_str().unwrap_or_default().to_string(); + return Ok((data_url, domain)); + } + Err("link preview image fetch failed".to_string()) +} + +fn sanitize_image( + bytes: &[u8], + declared_mime: &str, + preserve_transparency: bool, +) -> Result { + let sniffed = infer::get(bytes) + .map(|kind| kind.mime_type()) + .ok_or_else(|| "link preview image magic bytes are unsupported".to_string())?; + if sniffed != declared_mime { + return Err("link preview image content type does not match its bytes".to_string()); + } + let format = match sniffed { + "image/jpeg" => image::ImageFormat::Jpeg, + "image/png" => image::ImageFormat::Png, + "image/webp" => image::ImageFormat::WebP, + _ => return Err("link preview image type is unsupported".to_string()), + }; + if declares_animation(bytes, format) { + return Err("animated link preview images are unsupported".to_string()); + } - Ok(String::from_utf8_lossy(&bytes).into_owned()) + let reader = image::ImageReader::with_format(Cursor::new(bytes), format); + let mut decoder = reader + .into_decoder() + .map_err(|_| "link preview image is malformed".to_string())?; + let (width, height) = decoder.dimensions(); + if width == 0 + || height == 0 + || width > MAX_IMAGE_DIMENSION + || height > MAX_IMAGE_DIMENSION + || u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS + { + return Err("link preview image dimensions exceed safe limits".to_string()); + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_IMAGE_PIXELS * 4); + decoder + .set_limits(limits) + .map_err(|_| "link preview image exceeds safe decoding limits".to_string())?; + let orientation = decoder + .orientation() + .unwrap_or(image::metadata::Orientation::NoTransforms); + let mut decoded = image::DynamicImage::from_decoder(decoder) + .map_err(|_| "link preview image could not be decoded".to_string())?; + decoded.apply_orientation(orientation); + let decoded = decoded.thumbnail(MAX_SANITIZED_DIMENSION, MAX_SANITIZED_DIMENSION); + let mut output = Vec::new(); + if preserve_transparency && decoded.color().has_alpha() { + decoded + .write_to(&mut Cursor::new(&mut output), image::ImageFormat::Png) + .map_err(|_| "link preview image could not be sanitized".to_string())?; + return Ok(format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(output) + )); + } + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 82) + .encode_image(&decoded) + .map_err(|_| "link preview image could not be sanitized".to_string())?; + Ok(format!( + "data:image/jpeg;base64,{}", + BASE64_STANDARD.encode(output) + )) } -fn extract_google_title(html: &str) -> Option { - extract_meta_title(html) +fn declares_animation(bytes: &[u8], format: image::ImageFormat) -> bool { + match format { + image::ImageFormat::Png => bytes.windows(4).any(|chunk| chunk == b"acTL"), + image::ImageFormat::WebP => { + bytes.len() >= 21 + && bytes.starts_with(b"RIFF") + && &bytes[8..12] == b"WEBP" + && ((&bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0) + || bytes.windows(4).any(|chunk| chunk == b"ANIM")) + } + _ => false, + } +} + +fn extract_link_preview_metadata(html: &str) -> Option { + let title = extract_meta_content(html, "property", "og:title") + .or_else(|| extract_meta_content(html, "name", "twitter:title")) .or_else(|| extract_title_tag(html)) - .and_then(|title| normalize_google_title(&title)) + .and_then(|value| normalize_metadata_text(&value))?; + let site_name = extract_meta_content(html, "property", "og:site_name") + .and_then(|value| normalize_metadata_text(&value)); + let description = extract_meta_content(html, "property", "og:description") + .or_else(|| extract_meta_content(html, "name", "twitter:description")) + .and_then(|value| normalize_metadata_text(&value)); + + Some(LinkPreviewMetadata { + title, + site_name, + description, + image_data_url: None, + image_domain: None, + favicon_data_url: None, + }) } -fn extract_meta_title(html: &str) -> Option { +fn extract_meta_content(html: &str, key_attr: &str, key_value: &str) -> Option { let lower = html.to_ascii_lowercase(); let mut search_from = 0; @@ -121,14 +425,11 @@ fn extract_meta_title(html: &str) -> Option { }; let end = start + relative_end + 1; let tag = &html[start..end]; - let lower_tag = &lower[start..end]; - - if lower_tag.contains("og:title") || lower_tag.contains("twitter:title") { + if attr_value(tag, key_attr).is_some_and(|value| value.eq_ignore_ascii_case(key_value)) { if let Some(content) = attr_value(tag, "content") { return Some(content); } } - search_from = end; } @@ -140,7 +441,7 @@ fn extract_title_tag(html: &str) -> Option { let start = lower.find("')? + 1; let content_end = content_start + lower[content_start..].find("")?; - Some(html[content_start..content_end].to_string()) + Some(decode_html_entities(&html[content_start..content_end])) } fn attr_value(tag: &str, attr: &str) -> Option { @@ -157,62 +458,49 @@ fn attr_value(tag: &str, attr: &str) -> Option { && !matches!(after, Some(c) if c.is_ascii_alphanumeric() || c == '-' || c == '_'); if has_name_boundary { - let lower_rest = &lower[name_end..]; - let equals_offset = lower_rest.find('=')?; - let value_start = name_end + equals_offset + 1; - let value = tag[value_start..].trim_start(); + let rest = &tag[name_end..]; + let equals_offset = rest.find('=')?; + let value = rest[equals_offset + 1..].trim_start(); let quote = value.chars().next()?; - if quote == '"' || quote == '\'' { let value_body = &value[quote.len_utf8()..]; let value_end = value_body.find(quote)?; return Some(decode_html_entities(&value_body[..value_end])); } - let value_end = value .find(|c: char| c.is_ascii_whitespace() || c == '>') .unwrap_or(value.len()); return Some(decode_html_entities(&value[..value_end])); } - search_from = name_end; } None } -fn normalize_google_title(raw_title: &str) -> Option { - let mut title = decode_html_entities(raw_title) +fn normalize_metadata_text(raw: &str) -> Option { + let mut normalized = decode_html_entities(raw) .split_whitespace() .collect::>() .join(" "); - for suffix in [ " - Google Docs", " - Google Sheets", " - Google Slides", " - Google Drive", ] { - if let Some(stripped) = title.strip_suffix(suffix) { - title = stripped.trim().to_string(); + if let Some(stripped) = normalized.strip_suffix(suffix) { + normalized = stripped.trim().to_string(); break; } } - - match title.as_str() { - "" - | "Document" - | "Spreadsheet" - | "Presentation" - | "Drive file" - | "Drive folder" - | "Google Docs" - | "Google Sheets" - | "Google Slides" - | "Google Drive" - | "Sign in - Google Accounts" => None, - _ => Some(title.chars().take(180).collect()), + if matches!( + normalized.as_str(), + "" | "Sign in - Google Accounts" | "Google Docs" | "Google Sheets" | "Google Slides" + ) { + return None; } + Some(normalized.chars().take(MAX_METADATA_CHARS).collect()) } fn decode_html_entities(value: &str) -> String { @@ -231,71 +519,231 @@ fn decode_html_entities(value: &str) -> String { }; let end = start + relative_end + 1; let entity = &decoded[start + 2..end - 1]; - let parsed = if let Some(hex) = entity + let parsed = entity .strip_prefix('x') .or_else(|| entity.strip_prefix('X')) - { - u32::from_str_radix(hex, 16).ok() - } else { - entity.parse::().ok() - }; - + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .or_else(|| entity.parse::().ok()); let Some(ch) = parsed.and_then(char::from_u32) else { break; }; decoded.replace_range(start..end, &ch.to_string()); } - decoded } #[cfg(test)] mod tests { - use super::{extract_google_title, is_supported_google_link}; + use super::{ + declares_animation, extract_favicon_url, extract_image_url, extract_link_preview_metadata, + is_html_response, read_bytes_prefix, sanitize_image, LinkPreviewMetadata, + }; + use axum::{body::Body, http::Response, routing::get, Router}; + use base64::Engine as _; + use bytes::Bytes; + use futures_util::stream; + use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage}; + use std::{convert::Infallible, io::Cursor}; use url::Url; + async fn test_response(router: Router, path: &str) -> reqwest::Response { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() + } + #[test] - fn title_prefers_open_graph_title() { - let html = r#" - - - - Fallback - Google Docs - - - "#; + fn metadata_prefers_open_graph_and_reads_site_name() { + let html = r#" + + + Fallback"#; + assert_eq!( + extract_link_preview_metadata(html), + Some(LinkPreviewMetadata { + title: "Rich previews & cards".to_string(), + site_name: Some("Buzz".to_string()), + description: Some("Safe & useful previews".to_string()), + image_data_url: None, + image_domain: None, + favicon_data_url: None, + }) + ); + } + #[test] + fn metadata_falls_back_to_twitter_then_title() { + assert_eq!( + extract_link_preview_metadata("") + .map(|metadata| metadata.title), + Some("Tweet title".to_string()) + ); assert_eq!( - extract_google_title(html).as_deref(), - Some("Composer links & previews") + extract_link_preview_metadata(" Plain title ") + .map(|metadata| metadata.title), + Some("Plain title".to_string()) ); } #[test] - fn title_ignores_generic_google_titles() { + fn favicon_metadata_resolves_relative_icon_links() { + let page = Url::parse("https://example.com/articles/one").unwrap(); + let html = r#" + "#; assert_eq!( - extract_google_title("Sign in - Google Accounts"), + extract_favicon_url(html, &page).unwrap().as_str(), + "https://example.com/favicon.png" + ); + } + + #[test] + fn favicon_metadata_prefers_a_supported_raster_candidate() { + let page = Url::parse("https://github.com/block/buzz").unwrap(); + let html = r#" + + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://assets.example/favicon.png" + ); + } + + #[test] + fn favicon_metadata_uses_touch_icon_before_unsupported_ico() { + let page = Url::parse("https://twitter.com/tellaho").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://twitter.com/apple-touch-icon.png" + ); + } + + #[test] + fn image_metadata_resolves_relative_urls_and_prefers_open_graph() { + let page = Url::parse("https://example.com/articles/one").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_image_url(html, &page).unwrap().as_str(), + "https://example.com/preview.png" + ); + } + + #[tokio::test] + async fn oversized_html_uses_metadata_within_the_bounded_prefix() { + const LIMIT: usize = 256; + let metadata = r#""#; + let body = format!("{metadata}{}", "x".repeat(LIMIT)); + let response = test_response( + Router::new().route( + "/declared", + get(move || { + let body = body.clone(); + async move { + Response::builder() + .header("content-type", "text/html") + .body(Body::from(body)) + .unwrap() + } + }), + ), + "/declared", + ) + .await; + assert!(response + .content_length() + .is_some_and(|size| size > LIMIT as u64)); + assert!(is_html_response(&response)); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!( + extract_link_preview_metadata(&html).map(|metadata| metadata.title), + Some("Prefix title".to_string()) + ); + assert!(extract_image_url(&html, &Url::parse("https://example.com").unwrap()).is_some()); + } + + #[tokio::test] + async fn oversized_chunked_html_ignores_metadata_beyond_the_bounded_prefix() { + const LIMIT: usize = 256; + let response = test_response( + Router::new().route( + "/chunked", + get(|| async { + let chunks = stream::iter([ + Ok::<_, Infallible>(Bytes::from(vec![b'x'; LIMIT])), + Ok(Bytes::from_static( + br#""#, + )), + ]); + Response::builder() + .header("content-type", "text/html") + .body(Body::from_stream(chunks)) + .unwrap() + }), + ), + "/chunked", + ) + .await; + assert_eq!(response.content_length(), None); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!(extract_link_preview_metadata(&html), None); + assert_eq!( + extract_image_url(&html, &Url::parse("https://example.com").unwrap()), None ); - assert_eq!(extract_google_title("Google Docs"), None); } #[test] - fn supported_urls_are_google_file_links_only() { - assert!(is_supported_google_link( - &Url::parse("https://docs.google.com/document/d/abc/edit").unwrap() - )); - assert!(is_supported_google_link( - &Url::parse("https://docs.google.com/spreadsheets/d/abc/edit").unwrap() - )); - assert!(is_supported_google_link( - &Url::parse("https://drive.google.com/file/d/abc/view").unwrap() - )); - assert!(!is_supported_google_link( - &Url::parse("https://example.com/document/d/abc/edit").unwrap() - )); - assert!(!is_supported_google_link( - &Url::parse("http://docs.google.com/document/d/abc/edit").unwrap() - )); + fn sanitizer_rejects_mime_mismatch_and_outputs_static_jpeg() { + let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + assert!(sanitize_image(png.get_ref(), "image/jpeg", false).is_err()); + let sanitized = sanitize_image(png.get_ref(), "image/png", false).unwrap(); + assert!(sanitized.starts_with("data:image/jpeg;base64,")); + } + + #[test] + fn favicon_sanitizer_preserves_png_transparency() { + let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, Rgba([36, 41, 47, 0]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + + let sanitized = sanitize_image(png.get_ref(), "image/png", true).unwrap(); + assert!(sanitized.starts_with("data:image/png;base64,")); + let encoded = sanitized.split_once(',').unwrap().1; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha()); + } + + #[test] + fn animation_markers_are_rejected_before_decode() { + let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); + apng.extend_from_slice(b"junkacTLjunk"); + assert!(declares_animation(&apng, ImageFormat::Png)); + + let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec(); + webp.push(0x02); + assert!(declares_animation(&webp, ImageFormat::WebP)); + } + + #[test] + fn metadata_requires_a_non_empty_title() { + assert_eq!(extract_link_preview_metadata(" "), None); + assert_eq!(extract_link_preview_metadata(""), None); } } diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3d..4664a846e5 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -3,16 +3,18 @@ use tauri::{AppHandle, State}; mod forum; -use forum::{forum_message_from_event, forum_reply_from_event}; +use forum::{ + apply_link_preview_suppression, fetch_agent_owner_pubkeys, link_preview_suppression_targets, +}; +pub use forum::{get_forum_posts, get_forum_thread}; use crate::{ app_state::AppState, events, managed_agents::{find_managed_agent_mut, load_managed_agents, ManagedAgentRecord}, models::{ - FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse, - ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse, - ThreadRepliesResponse, + FeedItemInfo, FeedMeta, FeedResponse, FeedSections, SearchResponse, + SendChannelMessageResponse, ThreadRepliesResponse, }, nostr_convert, relay::{query_relay, submit_event, submit_event_with_keys}, @@ -113,9 +115,30 @@ pub async fn get_feed( Vec::new() }; + let mention_ids = mention_events + .iter() + .map(|event| event.id.to_hex()) + .collect::>(); + let mention_edits = if mention_ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ "kinds": [40003], "#e": mention_ids })], + ) + .await + .unwrap_or_default() + }; + let mention_owner_pubkeys = fetch_agent_owner_pubkeys(&state, &mention_events).await; + let suppressed_mentions = + link_preview_suppression_targets(&mention_events, &mention_edits, &mention_owner_pubkeys); let mentions: Vec = mention_events .iter() - .map(|ev| feed_item_from_event(ev, "mentions")) + .map(|ev| { + let mut item = feed_item_from_event(ev, "mentions"); + apply_link_preview_suppression(&mut item.tags, &item.id, &suppressed_mentions); + item + }) .collect(); let needs_action: Vec = approval_events .iter() @@ -206,79 +229,6 @@ pub async fn search_messages( Ok(nostr_convert::search_response_from_events(&events)) } -#[tauri::command] -pub async fn get_forum_posts( - channel_id: String, - limit: Option, - before: Option, - state: State<'_, AppState>, -) -> Result { - let cap = limit.unwrap_or(20).min(100); - let mut filter = serde_json::Map::new(); - filter.insert("kinds".to_string(), serde_json::json!([45001])); - filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()])); - filter.insert("limit".to_string(), serde_json::json!(cap)); - if let Some(t) = before { - filter.insert("until".to_string(), serde_json::json!(t)); - } - - let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; - let messages: Vec = events - .iter() - .map(|ev| forum_message_from_event(ev, &channel_id)) - .collect(); - - let next_cursor = messages.last().map(|m| m.created_at); - Ok(ForumPostsResponse { - messages, - next_cursor, - }) -} - -#[tauri::command] -pub async fn get_forum_thread( - channel_id: String, - event_id: String, - limit: Option, - cursor: Option, - state: State<'_, AppState>, -) -> Result { - let _ = (limit, cursor); - // Two filters: the root event itself, plus any reply (kinds 9/45003) - // that references it via #e. - let events = query_relay( - &state, - &[ - serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }), - serde_json::json!({ - "kinds": [9, 45003], - "#e": [event_id.clone()], - "#h": [channel_id.clone()], - }), - ], - ) - .await?; - - let mut root: Option = None; - let mut replies: Vec = Vec::new(); - for ev in &events { - if ev.id.to_hex() == event_id { - root = Some(forum_message_from_event(ev, &channel_id)); - } else { - replies.push(forum_reply_from_event(ev, &channel_id, &event_id)); - } - } - let total_replies = replies.len() as u32; - - let root = root.ok_or_else(|| "forum thread root event not found".to_string())?; - Ok(ForumThreadResponse { - root, - replies, - total_replies, - next_cursor: None, - }) -} - /// Fetch the full reply subtree under a thread root, server-side. /// /// Unlike the channel timeline (which the desktop assembles from its local @@ -919,38 +869,48 @@ pub async fn remove_reaction( Ok(()) } -#[tauri::command] -pub async fn edit_message( +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditMessageInput { channel_id: String, event_id: String, content: String, + #[serde(default)] media_tags: Vec>, - emoji_tags: Option>>, - // Pubkeys of mentions *newly added* by this edit (the composer diffs the - // edited body against the original). Only these get a `p` tag, so a typo-fix - // edit that leaves the mention set unchanged never re-wakes anyone. - mention_pubkeys: Option>, + #[serde(default)] + emoji_tags: Vec>, + // Pubkeys of mentions *newly added* by this edit. Only these get a `p` + // tag, so a typo-fix edit never re-wakes existing mentions. + #[serde(default)] + mention_pubkeys: Vec, + #[serde(default)] + suppress_link_previews: bool, +} + +#[tauri::command] +pub async fn edit_message( + input: EditMessageInput, state: State<'_, AppState>, ) -> Result<(), String> { - let channel_uuid = uuid::Uuid::parse_str(&channel_id) - .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; - let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; - let trimmed = content.trim(); + let channel_uuid = uuid::Uuid::parse_str(&input.channel_id) + .map_err(|_| format!("invalid channel UUID: {}", input.channel_id))?; + let target_eid = + EventId::from_hex(&input.event_id).map_err(|e| format!("invalid event ID: {e}"))?; + let trimmed = input.content.trim(); // Empty text is allowed when the edit still carries imeta attachments // (a media-only edit). Reject only when both are empty. - if trimmed.is_empty() && media_tags.is_empty() { + if trimmed.is_empty() && input.media_tags.is_empty() { return Err("edit must have content or attachments".into()); } - let emoji = emoji_tags.unwrap_or_default(); - let mentions = mention_pubkeys.unwrap_or_default(); - let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); + let mention_refs: Vec<&str> = input.mention_pubkeys.iter().map(|s| s.as_str()).collect(); let builder = events::build_message_edit( channel_uuid, target_eid, trimmed, - &media_tags, - &emoji, + &input.media_tags, + &input.emoji_tags, &mention_refs, + input.suppress_link_previews, )?; submit_event(builder, &state).await?; Ok(()) diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index ffcf3a62e0..086e8c9f79 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -1,4 +1,41 @@ -use crate::models::{ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary}; +use tauri::State; + +use crate::{ + app_state::AppState, + models::{ + ForumMessageInfo, ForumPostsResponse, ForumThreadReplyInfo, ForumThreadResponse, + ThreadSummary, + }, + relay::query_relay, +}; + +pub(super) async fn fetch_agent_owner_pubkeys( + state: &AppState, + events: &[nostr::Event], +) -> std::collections::HashMap { + let authors = events + .iter() + .map(|event| event.pubkey.to_hex()) + .collect::>() + .into_iter() + .collect::>(); + if authors.is_empty() { + return std::collections::HashMap::new(); + } + + super::query_relay( + state, + &[serde_json::json!({ "kinds": [0], "authors": authors })], + ) + .await + .unwrap_or_default() + .into_iter() + .filter_map(|profile| { + crate::nostr_convert::profile_valid_oa_owner_pubkey(&profile) + .map(|owner| (profile.pubkey.to_hex(), owner)) + }) + .collect() +} fn tags_to_vec(event: &nostr::Event) -> Vec> { event @@ -68,3 +105,214 @@ pub(super) fn forum_reply_from_event( reactions: serde_json::Value::Null, } } + +pub(super) fn link_preview_suppression_targets( + originals: &[nostr::Event], + edits: &[nostr::Event], + owner_pubkeys: &std::collections::HashMap, +) -> std::collections::HashSet { + let originals_by_id = originals + .iter() + .map(|event| (event.id.to_hex(), event)) + .collect::>(); + + edits + .iter() + .filter(|event| { + event.kind.as_u16() == 40003 + && event + .tags + .iter() + .any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()]) + }) + .filter_map(|edit| { + let target_id = edit.tags.iter().find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("e")) + .then(|| values.get(1).cloned()) + .flatten() + })?; + let target = originals_by_id.get(&target_id)?; + let author = target.pubkey.to_hex(); + let signer = edit.pubkey.to_hex(); + (signer == author || owner_pubkeys.get(&author) == Some(&signer)).then_some(target_id) + }) + .collect() +} + +pub(super) fn apply_link_preview_suppression( + tags: &mut Vec>, + event_id: &str, + suppressed: &std::collections::HashSet, +) { + if suppressed.contains(event_id) + && !tags + .iter() + .any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()]) + { + tags.push(vec!["link-preview".to_string(), "none".to_string()]); + } +} + +#[tauri::command] +pub async fn get_forum_posts( + channel_id: String, + limit: Option, + before: Option, + state: State<'_, AppState>, +) -> Result { + let cap = limit.unwrap_or(20).min(100); + let mut filter = serde_json::Map::new(); + filter.insert("kinds".to_string(), serde_json::json!([45001])); + filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()])); + filter.insert("limit".to_string(), serde_json::json!(cap)); + if let Some(t) = before { + filter.insert("until".to_string(), serde_json::json!(t)); + } + + let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; + let ids = events + .iter() + .map(|event| event.id.to_hex()) + .collect::>(); + let edits = if ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ "kinds": [40003], "#e": ids })], + ) + .await + .unwrap_or_default() + }; + let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await; + let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys); + let messages: Vec = events + .iter() + .map(|ev| { + let mut message = forum_message_from_event(ev, &channel_id); + apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed); + message + }) + .collect(); + + let next_cursor = messages.last().map(|m| m.created_at); + Ok(ForumPostsResponse { + messages, + next_cursor, + }) +} + +#[tauri::command] +pub async fn get_forum_thread( + channel_id: String, + event_id: String, + limit: Option, + cursor: Option, + state: State<'_, AppState>, +) -> Result { + let _ = (limit, cursor); + // Two filters: the root event itself, plus any reply (kinds 9/45003) + // that references it via #e. + let events = query_relay( + &state, + &[ + serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }), + serde_json::json!({ + "kinds": [9, 45003], + "#e": [event_id.clone()], + "#h": [channel_id.clone()], + }), + ], + ) + .await?; + let ids = events + .iter() + .map(|event| event.id.to_hex()) + .collect::>(); + let edits = if ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ "kinds": [40003], "#e": ids })], + ) + .await + .unwrap_or_default() + }; + let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await; + let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys); + + let mut root: Option = None; + let mut replies: Vec = Vec::new(); + for ev in &events { + if ev.id.to_hex() == event_id { + let mut message = forum_message_from_event(ev, &channel_id); + apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed); + root = Some(message); + } else if ev.kind.as_u16() as u32 != 40003 { + let mut reply = forum_reply_from_event(ev, &channel_id, &event_id); + apply_link_preview_suppression(&mut reply.tags, &reply.event_id, &suppressed); + replies.push(reply); + } + } + let total_replies = replies.len() as u32; + + let root = root.ok_or_else(|| "forum thread root event not found".to_string())?; + Ok(ForumThreadResponse { + root, + replies, + total_replies, + next_cursor: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + fn signed_event(keys: &Keys, kind: u16, tags: Vec>) -> nostr::Event { + let tags = tags + .into_iter() + .map(nostr::Tag::parse) + .collect::, _>>() + .expect("valid tags"); + EventBuilder::new(Kind::Custom(kind), "body") + .tags(tags) + .sign_with_keys(keys) + .expect("event signs") + } + + #[test] + fn suppression_targets_accepts_author_and_verified_owner_only() { + let author = Keys::generate(); + let owner = Keys::generate(); + let attacker = Keys::generate(); + let original = signed_event(&author, 9, Vec::new()); + let marker = vec!["link-preview".to_string(), "none".to_string()]; + let target = vec!["e".to_string(), original.id.to_hex()]; + let author_edit = signed_event(&author, 40003, vec![target.clone(), marker.clone()]); + let owner_edit = signed_event(&owner, 40003, vec![target.clone(), marker.clone()]); + let spoofed_edit = signed_event(&attacker, 40003, vec![target, marker]); + let owners = std::collections::HashMap::from([( + author.public_key().to_hex(), + owner.public_key().to_hex(), + )]); + + for edit in [&author_edit, &owner_edit] { + assert!(link_preview_suppression_targets( + std::slice::from_ref(&original), + std::slice::from_ref(edit), + &owners, + ) + .contains(&original.id.to_hex())); + } + assert!(link_preview_suppression_targets( + std::slice::from_ref(&original), + std::slice::from_ref(&spoofed_edit), + &owners, + ) + .is_empty()); + } +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..29df7b563d 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -180,7 +180,6 @@ pub fn build_leave(channel_id: Uuid) -> Result { } /// Kind 9002 — update channel name/description/visibility/ttl. -/// /// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the /// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`). pub fn build_update_channel( @@ -396,18 +395,8 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } -/// Kind 40003 — edit a message. Carries the full new content AND a fresh -/// imeta tag set; the receiver overlays the imeta tags onto the original -/// event so the rendered message reflects exactly the edited state. NIP-30 -/// custom-emoji tags ride along the same way so an edited body's `:shortcode:`s -/// stay resolvable (the send path attaches these too). -/// -/// `mentions` carries the pubkeys of mentions that are *newly added* by this -/// edit (the caller diffs the edited body against the original). Only those get -/// a `p` tag so the newly-mentioned party is notified/woken, while a typo-fix -/// edit that leaves the mention set unchanged emits no `p` tags and never -/// re-wakes anyone. This mirrors the send path's `mention_tags` (dedup + -/// lowercase); the receiver overlays these onto the original event's audience. +/// Kind 40003 — edit a message with full content, media, emoji, mentions, +/// and optional monotonic link-preview suppression. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, @@ -415,6 +404,7 @@ pub fn build_message_edit( media_tags: &[Vec], custom_emoji_tags: &[Vec], mentions: &[&str], + suppress_link_previews: bool, ) -> Result { check_content(content)?; let mut tags = vec![ @@ -424,6 +414,9 @@ pub fn build_message_edit( tags.extend(mention_tags(mentions)?); imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; + if suppress_link_previews { + tags.push(tag(vec!["link-preview", "none"])?); + } Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags)) } @@ -948,7 +941,8 @@ mod tests { let target = EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") .unwrap(); - let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap(); + let builder = + build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); let secret = nostr::SecretKey::from_hex( "0000000000000000000000000000000000000000000000000000000000000003", ) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7dcc5994ae..136582e6b1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -711,7 +711,7 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, - fetch_link_preview_title, + fetch_link_preview_metadata, discover_acp_auth_methods, discover_acp_providers, discover_git_bash_prerequisite, diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913f..aaa54ca5c1 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -13,6 +13,7 @@ import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; +import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts, @@ -65,6 +66,7 @@ function resetCommunityState({ } resetSidebarRelayConnectionCardState(); resetMediaCaches(); + resetLinkPreviewMetadataCache(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); clearSearchHitEventCache(); diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 1fb3c35cc4..ea8dce2afa 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -11,6 +11,7 @@ import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; +import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { formatRelativeTime } from "../lib/time"; @@ -121,6 +122,8 @@ export function ForumPostCard({ diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3ff..8ad4692d1d 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -16,6 +16,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; +import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; import { UserAvatar } from "@/shared/ui/UserAvatar"; export type InboxDisplayMessage = InboxContextMessage & { @@ -212,6 +213,10 @@ export function InboxMessageRow({ isKnownAgentPubkey, )} content={message.content} + messageId={message.id} + linkPreviewsSuppressed={hasLinkPreviewSuppression( + timelineMessage.tags, + )} customEmoji={customEmoji} mentionNames={message.mentionNames} mentionPubkeysByName={message.mentionPubkeysByName} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 926738a60b..ee4cc628f2 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -674,3 +674,102 @@ test("CHANNEL_TIMELINE_CONTENT_KINDS matches isTimelineContentEvent", () => { ); } }); + +test("original message link-preview none marker suppresses all generated previews", () => { + const [message] = formatTimelineMessages( + [ + streamMessage({ + content: "https://one.example https://two.example", + tags: [ + ["h", CHANNEL_ID], + ["link-preview", "none"], + ], + }), + ], + null, + undefined, + null, + ); + assert.deepEqual( + message.tags.find((tag) => tag[0] === "link-preview"), + ["link-preview", "none"], + ); +}); + +test("authorized suppression edit remains monotonic across later body edits", () => { + const suppress = streamEdit( + HEX64_A, + "https://one.example https://two.example", + { + created_at: 1_700_000_001, + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ["link-preview", "none"], + ], + }, + ); + const later = streamEdit(HEX64_A, "later body", { + id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + created_at: 1_700_000_002, + }); + const [message] = formatTimelineMessages( + [streamMessage(), suppress, later], + null, + undefined, + null, + ); + assert.equal(message.body, "later body"); + assert.equal( + message.tags.some((tag) => tag[0] === "link-preview" && tag[1] === "none"), + true, + ); +}); + +test("spoofed suppression edit cannot hide another author's previews", () => { + const spoof = streamEdit(HEX64_A, "spoofed", { + pubkey: PUBKEY_B, + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ["link-preview", "none"], + ], + }); + const [message] = formatTimelineMessages( + [streamMessage(), spoof], + null, + undefined, + null, + ); + assert.equal(message.body, "hello world"); + assert.equal( + message.tags.some((tag) => tag[0] === "link-preview"), + false, + ); +}); + +test("verified agent owner may publish a suppression edit", () => { + const ownerEdit = streamEdit(HEX64_A, "owner edit", { + pubkey: PUBKEY_B, + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ["link-preview", "none"], + ], + }); + const profiles = { + [PUBKEY_A]: { ownerPubkey: PUBKEY_B }, + }; + const [message] = formatTimelineMessages( + [streamMessage(), ownerEdit], + null, + undefined, + null, + profiles, + ); + assert.equal(message.body, "owner edit"); + assert.equal( + message.tags.some((tag) => tag[0] === "link-preview"), + true, + ); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 640c12bb75..ab35ecfcc4 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -181,6 +181,36 @@ function getAuthorAvatarUrl(input: { return profiles?.[authorPubkey.toLowerCase()]?.avatarUrl ?? null; } +export function hasLinkPreviewSuppression( + tags: string[][] | undefined, +): boolean { + return ( + tags?.some( + (tag) => + tag[0] === "link-preview" && tag[1] === "none" && tag.length === 2, + ) ?? false + ); +} + +function isAuthorizedMessageEdit( + edit: RelayEvent, + target: RelayEvent, + profiles: UserProfileLookup | undefined, + relaySelfPubkey?: string | null, +): boolean { + const author = normalizePubkey( + resolveEventAuthorPubkey({ + event: target, + preferActorTag: true, + relaySelfPubkey, + requireChannelTagForPTags: true, + }), + ); + const signer = normalizePubkey(edit.pubkey); + if (signer === author) return true; + return normalizePubkey(profiles?.[author]?.ownerPubkey ?? "") === signer; +} + export function formatTimelineMessages( events: RelayEvent[], channel: Channel | null, @@ -219,8 +249,14 @@ export function formatTimelineMessages( } } - // Build a map of latest edit per original message: targetId → { content, tags, createdAt }. - // When multiple edits exist for the same message, the most recent one wins. + const timelineEventsById = new Map( + events.filter(isTimelineContentEvent).map((event) => [event.id, event]), + ); + const previewSuppressedTargetIds = new Set(); + + // Build a map of latest authorized edit per original message. Preview + // suppression is monotonic: any authorized edit carrying the marker wins + // forever, independent of which edit supplies the latest body. // The edit's own tags are kept so the renderer can overlay imeta tags // (attachments) from the edit onto the original event — non-imeta tags on // the original (`h`, `p` mentions, etc.) stay untouched. @@ -240,6 +276,16 @@ export function formatTimelineMessages( if (!targetId || deletedEventIds.has(targetId)) { continue; } + const target = timelineEventsById.get(targetId); + if ( + !target || + !isAuthorizedMessageEdit(event, target, profiles, relaySelfPubkey) + ) { + continue; + } + if (hasLinkPreviewSuppression(event.tags)) { + previewSuppressedTargetIds.add(targetId); + } const existing = editsByTargetId.get(targetId); if (!existing || event.created_at > existing.createdAt) { @@ -469,7 +515,18 @@ export function formatTimelineMessages( // imeta tags. All non-imeta tags on the original are preserved. // Logic lives in `applyEditTagOverlay.mjs` so prod and tests share // a single source. - tags: applyEditTagOverlay(event.tags, edit?.tags), + tags: (() => { + const effectiveTags = applyEditTagOverlay(event.tags, edit?.tags); + if ( + hasLinkPreviewSuppression(event.tags) || + previewSuppressedTargetIds.has(event.id) + ) { + return hasLinkPreviewSuppression(effectiveTags) + ? effectiveTags + : [...effectiveTags, ["link-preview", "none"]]; + } + return effectiveTags; + })(), reactions: (() => { const reactions = reactionsByEventId.get(event.id); if (!reactions) return undefined; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 688b5d5f0d..ffe47a0882 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -41,6 +41,9 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; +import { editMessage } from "@/shared/api/tauri"; +import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; +import { toast } from "sonner"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; @@ -149,6 +152,29 @@ export const MessageRow = React.memo( const [expandedDiffId, setExpandedDiffId] = React.useState( null, ); + const linkPreviewsSuppressed = hasLinkPreviewSuppression(message.tags); + const removeLinkPreviewsForEveryone = + channelId && onEdit && !message.pending && !linkPreviewsSuppressed + ? async () => { + const tags = message.tags ?? []; + try { + await editMessage( + channelId, + message.id, + message.body, + tags.filter((tag) => tag[0] === "imeta"), + tags.filter((tag) => tag[0] === "emoji"), + undefined, + true, + ); + } catch (error) { + toast.error( + `Failed to remove previews: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } + } + : undefined; const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); @@ -370,6 +396,9 @@ export const MessageRow = React.memo( isKnownAgentPubkey, )} content={message.body} + messageId={message.id} + linkPreviewsSuppressed={linkPreviewsSuppressed} + onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone} customEmoji={customEmoji} imetaByUrl={imetaByUrl} agentMentionPubkeysByName={agentMentionPubkeysByName} diff --git a/desktop/src/shared/api/editMessage.ts b/desktop/src/shared/api/editMessage.ts new file mode 100644 index 0000000000..fc63502e49 --- /dev/null +++ b/desktop/src/shared/api/editMessage.ts @@ -0,0 +1,23 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export async function editMessage( + channelId: string, + eventId: string, + content: string, + mediaTags?: string[][], + emojiTags?: string[][], + mentionPubkeys?: string[], + suppressLinkPreviews?: boolean, +): Promise { + await invokeTauri("edit_message", { + input: { + channelId, + eventId, + content, + mediaTags: mediaTags ?? [], + emojiTags: emojiTags ?? [], + mentionPubkeys: mentionPubkeys ?? [], + suppressLinkPreviews: suppressLinkPreviews ?? false, + }, + }); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec..a42cca1df3 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -614,23 +614,7 @@ export async function uploadMediaBytes( }); } -export async function editMessage( - channelId: string, - eventId: string, - content: string, - mediaTags?: string[][], - emojiTags?: string[][], - mentionPubkeys?: string[], -): Promise { - await invokeTauri("edit_message", { - channelId, - eventId, - content, - mediaTags: mediaTags ?? [], - emojiTags: emojiTags ?? [], - mentionPubkeys: mentionPubkeys ?? null, - }); -} +export { editMessage } from "@/shared/api/editMessage"; export async function deleteMessage( channelId: string, diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index a56b35f54a..66ab404ace 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -198,7 +198,7 @@ test("extractSupportedLinkPreviews skips markdown image link URLs", () => { ); }); -test("extractSupportedLinkPreviews requires bare URL boundaries", () => { +test("extractSupportedLinkPreviews treats other absolute HTTPS URLs as generic", () => { assert.deepEqual( extractSupportedLinkPreviews( [ @@ -207,7 +207,7 @@ test("extractSupportedLinkPreviews requires bare URL boundaries", () => { "(https://github.com/block/sprout/pull/2)", ].join(" "), ).map((preview) => preview.title), - ["block/sprout #2"], + ["evil-github.com", "example.com", "block/sprout #2"], ); }); @@ -252,3 +252,39 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => { ); assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false); }); + +test("parseSupportedLinkPreview parses generic HTTPS URLs", () => { + assert.deepEqual( + parseSupportedLinkPreview("https://example.com/articles/rich-previews"), + { + kind: "generic-link", + href: "https://example.com/articles/rich-previews", + provider: "example.com", + title: "example.com", + typeLabel: "link", + }, + ); +}); + +test("parseSupportedLinkPreview rejects generic HTTP URLs", () => { + assert.equal( + parseSupportedLinkPreview("http://example.com/articles/rich-previews"), + null, + ); +}); + +test("extractSupportedLinkPreviews finds generic links and preserves exclusions", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + [ + "Read https://example.com/article first.", + "`https://hidden.example.com/secret`", + "then [the details](https://docs.example.org/details)", + ].join(" "), + ).map(({ kind, title }) => ({ kind, title })), + [ + { kind: "generic-link", title: "example.com" }, + { kind: "generic-link", title: "the details" }, + ], + ); +}); diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 5cba96da87..6141d48310 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -7,19 +7,17 @@ export type SupportedLinkPreviewKind = | "google-drive-folder" | "google-docs-document" | "google-sheets-spreadsheet" - | "google-slides-presentation"; + | "google-slides-presentation" + | "generic-link"; export type SupportedLinkPreview = { kind: SupportedLinkPreviewKind; href: string; - provider: - | "GitHub" - | "Linear" - | "Google Drive" - | "Google Docs" - | "Google Sheets" - | "Google Slides"; + provider: string; title: string; + /** Sanitized native-fetched bitmap; never a remote URL. */ + imageDataUrl?: string | null; + imageDomain?: string | null; typeLabel: | "PR" | "issue" @@ -28,13 +26,14 @@ export type SupportedLinkPreview = { | "folder" | "document" | "spreadsheet" - | "presentation"; + | "presentation" + | "link"; }; const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; const MARKDOWN_SUPPORTED_LINK_RE = - /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; + /!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; type HiddenRange = { @@ -431,12 +430,27 @@ export function parseSupportedLinkPreview( return null; } - return ( + const recognized = parseGithubLink(parsed) ?? parseLinearIssue(parsed) ?? parseGoogleDriveLink(parsed) ?? - parseGoogleDocsLink(parsed) - ); + parseGoogleDocsLink(parsed); + if (recognized) return recognized; + const hostname = normalizeHostname(parsed); + if ( + parsed.protocol !== "https:" || + [ + "github.com", + "linear.app", + "drive.google.com", + "docs.google.com", + ].includes(hostname) + ) { + return null; + } + + const provider = hostname; + return createPreview("generic-link", parsed, provider, "link", provider); } export function isSupportedLinkAutolinkLabel( diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs new file mode 100644 index 0000000000..a594e316b7 --- /dev/null +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveLinkPreview } from "./useResolvedLinkPreviews.ts"; + +const preview = { + kind: "generic-link", + href: "https://example.com/story", + provider: "example.com", + title: "example.com/story", + typeLabel: "link", +}; + +test("pending metadata reserves the image treatment", () => { + assert.deepEqual(resolveLinkPreview(preview, undefined), { + ...preview, + imageState: "pending", + }); +}); + +test("resolved image metadata keeps the reserved image treatment", () => { + const resolved = resolveLinkPreview(preview, { + title: "A story", + siteName: "Example", + imageDataUrl: "data:image/jpeg;base64,abc", + imageDomain: "cdn.example.com", + }); + + assert.equal(resolved.imageState, "image"); + assert.equal(resolved.provider, "Example"); + assert.equal(resolved.imageDomain, "cdn.example.com"); +}); + +test("resolved metadata without a complete image collapses to the compact treatment", () => { + const resolved = resolveLinkPreview(preview, { + title: "A story", + siteName: "Example", + imageDataUrl: null, + imageDomain: null, + }); + + assert.equal(resolved.imageState, "none"); + assert.equal(resolved.imageDataUrl, null); + assert.equal(resolved.imageDomain, null); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 9773c33945..71d610ff2b 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -4,61 +4,107 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { SupportedLinkPreview } from "./linkPreview"; -const GOOGLE_FALLBACK_TITLES = new Set([ - "Drive file", - "Drive folder", - "Document", - "Spreadsheet", - "Presentation", -]); - -const titleCache = new Map | string | null>(); - -function fetchLinkPreviewTitle(href: string): Promise { - return invokeTauri("fetch_link_preview_title", { href }); +type LinkPreviewMetadata = { + title: string; + siteName: string | null; + description: string | null; + imageDataUrl: string | null; + imageDomain: string | null; + faviconDataUrl?: string | null; +}; + +const metadataCache = new Map< + string, + Promise | LinkPreviewMetadata | null +>(); + +/** Clear ephemeral metadata when the active relay/community changes. */ +export function resetLinkPreviewMetadataCache(): void { + metadataCache.clear(); } -function shouldResolveTitle(preview: SupportedLinkPreview): boolean { - return ( - preview.kind.startsWith("google-") && - GOOGLE_FALLBACK_TITLES.has(preview.title) +function fetchLinkPreviewMetadata( + href: string, +): Promise { + return invokeTauri( + "fetch_link_preview_metadata", + { + href, + }, ); } -function cacheTitle(href: string): Promise { - const cached = titleCache.get(href); +function cacheMetadata(href: string): Promise { + const cached = metadataCache.get(href); if (cached instanceof Promise) return cached; if (cached !== undefined) return Promise.resolve(cached); - const promise = fetchLinkPreviewTitle(href) - .then((title) => { - titleCache.set(href, title); - return title; + const promise = fetchLinkPreviewMetadata(href) + .then((metadata) => { + metadataCache.set(href, metadata); + return metadata; }) .catch(() => { - titleCache.set(href, null); + metadataCache.set(href, null); return null; }); - titleCache.set(href, promise); + metadataCache.set(href, promise); return promise; } +export type LinkPreviewImageState = "pending" | "image" | "none"; + +export type ResolvedLinkPreview = SupportedLinkPreview & { + description?: string | null; + faviconDataUrl?: string | null; + imageState: LinkPreviewImageState; +}; + +type ResolvedMetadataByHref = Record< + string, + LinkPreviewMetadata | null | undefined +>; + +export function resolveLinkPreview( + preview: SupportedLinkPreview, + metadata: LinkPreviewMetadata | null | undefined, +): ResolvedLinkPreview { + if (metadata === undefined) { + return { ...preview, imageState: "pending" }; + } + if (metadata === null) { + return { ...preview, imageState: "none" }; + } + + const hasImage = Boolean(metadata.imageDataUrl && metadata.imageDomain); + return { + ...preview, + title: metadata.title, + description: metadata.description, + faviconDataUrl: metadata.faviconDataUrl, + provider: + preview.kind === "generic-link" && metadata.siteName + ? metadata.siteName + : preview.provider, + imageDataUrl: hasImage ? metadata.imageDataUrl : null, + imageDomain: hasImage ? metadata.imageDomain : null, + imageState: hasImage ? "image" : "none", + }; +} + export function useResolvedLinkPreviews( previews: SupportedLinkPreview[], -): SupportedLinkPreview[] { - const [resolvedTitles, setResolvedTitles] = React.useState< - Record - >({}); +): ResolvedLinkPreview[] { + const [resolvedMetadata, setResolvedMetadata] = + React.useState({}); React.useEffect(() => { let cancelled = false; - const pending = previews.filter(shouldResolveTitle); - if (pending.length === 0) return undefined; - for (const preview of pending) { - const cached = titleCache.get(preview.href); - if (typeof cached === "string" && cached) { - setResolvedTitles((current) => + for (const preview of previews) { + const cached = metadataCache.get(preview.href); + if (cached && !(cached instanceof Promise)) { + setResolvedMetadata((current) => current[preview.href] === cached ? current : { ...current, [preview.href]: cached }, @@ -66,12 +112,12 @@ export function useResolvedLinkPreviews( continue; } - void cacheTitle(preview.href).then((title) => { - if (cancelled || !title) return; - setResolvedTitles((current) => - current[preview.href] === title + void cacheMetadata(preview.href).then((metadata) => { + if (cancelled) return; + setResolvedMetadata((current) => + current[preview.href] === metadata ? current - : { ...current, [preview.href]: title }, + : { ...current, [preview.href]: metadata }, ); }); } @@ -83,10 +129,9 @@ export function useResolvedLinkPreviews( return React.useMemo( () => - previews.map((preview) => { - const title = resolvedTitles[preview.href]; - return title ? { ...preview, title } : preview; - }), - [previews, resolvedTitles], + previews.map((preview) => + resolveLinkPreview(preview, resolvedMetadata[preview.href]), + ), + [previews, resolvedMetadata], ); } diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 5d6b80767a..da225aec7b 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -1,11 +1,12 @@ -import { ExternalLink } from "lucide-react"; +import { Globe, X } from "lucide-react"; -import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; import { Attachment, - AttachmentActions, AttachmentContent, + AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger, @@ -89,7 +90,27 @@ function GoogleSlidesLogo({ className }: { className?: string }) { ); } -function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { +function getHostname(preview: ResolvedLinkPreview): string { + try { + return new URL(preview.href).hostname.replace(/^www\./, ""); + } catch { + return preview.provider; + } +} + +function LinkPreviewLogo({ preview }: { preview: ResolvedLinkPreview }) { + if (preview.faviconDataUrl) { + return ( + + ); + } + switch (preview.kind) { case "github-issue": case "github-pull-request": @@ -106,53 +127,119 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { return ; case "google-slides-presentation": return ; + case "generic-link": + return