diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 1fd57dae6..b5a0674c0 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -1,4 +1,7 @@ -use std::{net::IpAddr, 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::{ @@ -9,6 +12,10 @@ use serde::Serialize; use url::Url; 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; @@ -19,6 +26,8 @@ const MAX_METADATA_CHARS: usize = 180; pub struct LinkPreviewMetadata { title: String, site_name: Option, + image_data_url: Option, + image_domain: Option, } #[tauri::command] @@ -40,7 +49,7 @@ async fn fetch_link_preview_metadata_inner( validate_public_https_url(&url).await?; for redirect_count in 0..=MAX_REDIRECTS { - let response = send_pinned_request(&url).await?; + 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 { @@ -69,8 +78,18 @@ async fn fetch_link_preview_metadata_inner( return Ok(None); } - let body = read_limited_text(response).await?; - return Ok(extract_link_preview_metadata(&body)); + let body = read_limited_bytes(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).await { + metadata.image_data_url = Some(data_url); + metadata.image_domain = Some(domain); + } + } + return Ok(Some(metadata)); } Ok(None) @@ -108,7 +127,7 @@ async fn resolve_public_addresses(host: &str) -> Result, String> { Ok(addresses) } -async fn send_pinned_request(url: &Url) -> Result { +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())?; @@ -126,7 +145,7 @@ async fn send_pinned_request(url: &Url) -> Result { .map_err(|error| format!("link preview client failed: {error}"))?; let request = client .get(url.as_str()) - .header(ACCEPT, "text/html,application/xhtml+xml;q=0.9") + .header(ACCEPT, accept) .header(USER_AGENT, "Buzz Desktop link preview"); tokio::time::timeout(PREVIEW_FETCH_TIMEOUT, request.send()) @@ -154,19 +173,150 @@ fn is_html_response(response: &reqwest::Response) -> bool { .is_none_or(|size| size <= MAX_PREVIEW_FETCH_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 link preview failed: {error}"))?; - if bytes.len() + chunk.len() > MAX_PREVIEW_FETCH_BYTES { + 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) +} - Ok(String::from_utf8_lossy(&bytes).into_owned()) +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) -> 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)) + .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) -> 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()); + } + + 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(); + 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 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 { @@ -177,7 +327,12 @@ fn extract_link_preview_metadata(html: &str) -> Option { let site_name = extract_meta_content(html, "property", "og:site_name") .and_then(|value| normalize_metadata_text(&value)); - Some(LinkPreviewMetadata { title, site_name }) + Some(LinkPreviewMetadata { + title, + site_name, + image_data_url: None, + image_domain: None, + }) } fn extract_meta_content(html: &str, key_attr: &str, key_value: &str) -> Option { @@ -300,7 +455,13 @@ fn decode_html_entities(value: &str) -> String { #[cfg(test)] mod tests { - use super::{extract_link_preview_metadata, LinkPreviewMetadata}; + use super::{ + declares_animation, extract_image_url, extract_link_preview_metadata, sanitize_image, + LinkPreviewMetadata, + }; + use image::{DynamicImage, ImageFormat, Rgb, RgbImage}; + use std::io::Cursor; + use url::Url; #[test] fn metadata_prefers_open_graph_and_reads_site_name() { @@ -312,6 +473,8 @@ mod tests { Some(LinkPreviewMetadata { title: "Rich previews & cards".to_string(), site_name: Some("Buzz".to_string()), + image_data_url: None, + image_domain: None, }) ); } @@ -330,6 +493,38 @@ mod tests { ); } + #[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" + ); + } + + #[test] + 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").is_err()); + let sanitized = sanitize_image(png.get_ref(), "image/png").unwrap(); + assert!(sanitized.starts_with("data:image/jpeg;base64,")); + } + + #[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); diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 1fb3c35cc..0b18767df 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -121,6 +121,7 @@ export function ForumPostCard({ diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3f..35aaeed8d 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -212,6 +212,7 @@ export function InboxMessageRow({ isKnownAgentPubkey, )} content={message.content} + messageId={message.id} customEmoji={customEmoji} mentionNames={message.mentionNames} mentionPubkeysByName={message.mentionPubkeysByName} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 688b5d5f0..3a23c3217 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -370,6 +370,7 @@ export const MessageRow = React.memo( isKnownAgentPubkey, )} content={message.body} + messageId={message.id} customEmoji={customEmoji} imetaByUrl={imetaByUrl} agentMentionPubkeysByName={agentMentionPubkeysByName} diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 97a4efc58..6141d4831 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -15,6 +15,9 @@ export type SupportedLinkPreview = { href: string; provider: string; title: string; + /** Sanitized native-fetched bitmap; never a remote URL. */ + imageDataUrl?: string | null; + imageDomain?: string | null; typeLabel: | "PR" | "issue" diff --git a/desktop/src/shared/lib/linkPreviewImageVisibility.test.mjs b/desktop/src/shared/lib/linkPreviewImageVisibility.test.mjs new file mode 100644 index 000000000..41a7d88c1 --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewImageVisibility.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + linkPreviewImageKey, + readHiddenPreviewImages, +} from "./linkPreviewImageVisibility.ts"; + +test("preview image visibility keys are scoped to message and link", () => { + assert.notEqual( + linkPreviewImageKey("message-a", "https://example.com"), + linkPreviewImageKey("message-b", "https://example.com"), + ); +}); + +test("hidden preview storage rejects malformed entries", () => { + const storage = { + getItem: () => + JSON.stringify([ + null, + { key: 1, hiddenAt: "bad" }, + { key: "ok", hiddenAt: 2 }, + ]), + }; + assert.deepEqual(readHiddenPreviewImages(storage), [ + { key: "ok", hiddenAt: 2 }, + ]); +}); diff --git a/desktop/src/shared/lib/linkPreviewImageVisibility.ts b/desktop/src/shared/lib/linkPreviewImageVisibility.ts new file mode 100644 index 000000000..08caf95ea --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewImageVisibility.ts @@ -0,0 +1,41 @@ +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const STORAGE_KEY = "buzz-hidden-link-preview-images.v1"; +const MAX_ENTRIES = 1_000; + +type HiddenPreviewImage = { key: string; hiddenAt: number }; + +export function linkPreviewImageKey(messageId: string, href: string): string { + return `${messageId}:${href}`; +} + +export function readHiddenPreviewImages( + storage: Pick = window.localStorage, +): HiddenPreviewImage[] { + try { + const parsed: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? "[]"); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (entry): entry is HiddenPreviewImage => + typeof entry === "object" && + entry !== null && + typeof (entry as HiddenPreviewImage).key === "string" && + typeof (entry as HiddenPreviewImage).hiddenAt === "number", + ) + .slice(-MAX_ENTRIES); + } catch { + return []; + } +} + +export function hidePreviewImage(key: string): void { + const entries = readHiddenPreviewImages().filter( + (entry) => entry.key !== key, + ); + entries.push({ key, hiddenAt: Date.now() }); + setLocalStorageItemWithRecovery( + STORAGE_KEY, + JSON.stringify(entries.slice(-MAX_ENTRIES)), + ); +} diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 7383f9f36..3189b5d94 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -7,6 +7,8 @@ import type { SupportedLinkPreview } from "./linkPreview"; type LinkPreviewMetadata = { title: string; siteName: string | null; + imageDataUrl: string | null; + imageDomain: string | null; }; const metadataCache = new Map< @@ -96,6 +98,8 @@ export function useResolvedLinkPreviews( preview.kind === "generic-link" && metadata.siteName ? metadata.siteName : preview.provider, + imageDataUrl: metadata.imageDataUrl, + imageDomain: metadata.imageDomain, }; }), [previews, resolvedMetadata], diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 48a22de0c..7e6e8cee5 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -1,7 +1,14 @@ -import { ExternalLink, Globe } from "lucide-react"; +import * as React from "react"; +import { ExternalLink, Globe, EyeOff } from "lucide-react"; import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; +import { + hidePreviewImage, + linkPreviewImageKey, + readHiddenPreviewImages, +} from "@/shared/lib/linkPreviewImageVisibility"; +import { Button } from "@/shared/ui/button"; import { Attachment, AttachmentActions, @@ -113,11 +120,32 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { export function LinkPreviewAttachment({ className, + messageId, preview, }: { className?: string; + messageId?: string; preview: SupportedLinkPreview; }) { + const visibilityKey = messageId + ? linkPreviewImageKey(messageId, preview.href) + : null; + const [imageHidden, setImageHidden] = React.useState(() => + visibilityKey + ? readHiddenPreviewImages().some((entry) => entry.key === visibilityKey) + : false, + ); + React.useEffect(() => { + setImageHidden( + visibilityKey + ? readHiddenPreviewImages().some((entry) => entry.key === visibilityKey) + : false, + ); + }, [visibilityKey]); + const showImage = Boolean( + messageId && preview.imageDataUrl && preview.imageDomain && !imageHidden, + ); + return ( - - - - + {showImage ? ( +
+ {`Automatic + +
+ Automatic preview from {preview.imageDomain} +
+
+ ) : null} + {!showImage ? ( + + + + ) : null} +
{preview.provider} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3..b6029e96d 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1837,6 +1837,7 @@ function MarkdownInner({ interactive = true, agentMentionPubkeysByName, mediaInset = false, + messageId, mentionNames, mentionPubkeysByName, searchQuery, @@ -1977,7 +1978,11 @@ function MarkdownInner({ data-link-preview-list="" > {resolvedLinkPreviews.map((preview) => ( - + ))} ) : null} @@ -1995,6 +2000,7 @@ export const Markdown = React.memo( prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && prev.mediaInset === next.mediaInset && + prev.messageId === next.messageId && shallowRecordEqual( prev.agentMentionPubkeysByName, next.agentMentionPubkeysByName, diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 63551024d..a4587b731 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -59,6 +59,8 @@ export type MarkdownProps = { mentionNames?: string[]; mentionPubkeysByName?: Record; mediaInset?: boolean; + /** Event/message identity used only for local preview-image visibility. */ + messageId?: string; searchQuery?: string; /** Display name shown in shared-agent card metadata. */ snapshotSharedBy?: string;