diff --git a/crates/buzz-relay/src/handlers/imeta.rs b/crates/buzz-relay/src/handlers/imeta.rs index b75060ce6f..e3d564e448 100644 --- a/crates/buzz-relay/src/handlers/imeta.rs +++ b/crates/buzz-relay/src/handlers/imeta.rs @@ -369,6 +369,55 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> { } } +/// Validate an authored link-preview media URL/hash pair. +/// +/// Empty pairs are allowed. Non-empty media must be an exact, credential-free +/// URL on this tenant's media origin with a content-addressed image path. +pub fn validate_local_image_media_pair( + media_url: &str, + sha256: &str, + media_base_url: &str, +) -> bool { + const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"]; + if media_url.is_empty() && sha256.is_empty() { + return true; + } + if media_url.is_empty() + || sha256.len() != 64 + || !sha256.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return false; + } + + let Ok(parsed) = url::Url::parse(media_url) else { + return false; + }; + let Ok(base) = url::Url::parse(media_base_url) else { + return false; + }; + if parsed.scheme() != base.scheme() + || parsed.host_str() != base.host_str() + || parsed.port_or_known_default() != base.port_or_known_default() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return false; + } + + let Some(filename) = parsed.path().strip_prefix("/media/") else { + return false; + }; + let Some((path_hash, ext)) = filename.split_once('.') else { + return false; + }; + !filename.contains('/') + && !filename.contains('%') + && path_hash == sha256 + && IMAGE_EXTS.contains(&ext) +} + /// Validate that a URL references a valid local media blob path. fn is_local_media_url(url: &str, media_base_url: &str) -> bool { // A safe extension token: 1–8 lowercase alphanumeric chars. Covers media @@ -423,6 +472,48 @@ mod tests { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; const BASE: &str = "https://relay.example.com/media"; + #[test] + fn link_preview_media_pair_requires_exact_local_image_url_and_hash() { + let hash = HASH; + let valid = format!("{BASE}/{hash}.png"); + assert!(validate_local_image_media_pair(&valid, hash, BASE)); + assert!(!validate_local_image_media_pair( + &format!("https://evil.example/media/{hash}.png"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png?token=leak"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png#fragment"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("https://user@relay.example.com/media/{hash}.png"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.svg"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png/extra"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png"), + &"0".repeat(64), + BASE + )); + } + #[test] fn test_local_media_url_relative() { assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE)); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728..0cb6178bef 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -162,6 +162,52 @@ pub fn reject_with_transport(transport: &'static str, reason: &'static str) { .increment(1); } +fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(), String> { + const MAX_SNAPSHOTS: usize = 8; + const MAX_TITLE: usize = 300; + const MAX_SITE: usize = 100; + const MAX_DESCRIPTION: usize = 1000; + + let mut count = 0; + let mut seen = std::collections::HashSet::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("link-preview") { + continue; + } + count += 1; + if count > MAX_SNAPSHOTS || parts.len() != 11 || parts[1] != "snapshot" || parts[2] != "1" { + return Err("invalid link-preview snapshot tag".into()); + } + let canonical = + url::Url::parse(&parts[3]).map_err(|_| "invalid link-preview canonical URL")?; + if canonical.scheme() != "https" + || !canonical.username().is_empty() + || canonical.password().is_some() + || canonical.fragment().is_some() + || !seen.insert(parts[3].clone()) + || !event.content.contains(&parts[3]) + { + return Err("invalid link-preview canonical URL".into()); + } + for (value, max) in [ + (&parts[4], MAX_TITLE), + (&parts[5], MAX_SITE), + (&parts[6], MAX_DESCRIPTION), + ] { + if value.len() > max || value.chars().any(char::is_control) { + return Err("invalid link-preview snapshot text".into()); + } + } + if !super::imeta::validate_local_image_media_pair(&parts[7], &parts[8], media_base_url) + || !super::imeta::validate_local_image_media_pair(&parts[9], &parts[10], media_base_url) + { + return Err("link-preview media must reference matching local image blobs".into()); + } + } + Ok(()) +} + /// Successful ingestion result. pub struct IngestResult { /// Hex-encoded event ID. @@ -2596,6 +2642,13 @@ async fn ingest_event_inner( }); } + let tenant_media_base = + crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + if kind_u32 == KIND_STREAM_MESSAGE { + validate_link_preview_tags(&event, &tenant_media_base) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + let imeta_tags: Vec> = event .tags .iter() @@ -2603,8 +2656,6 @@ async fn ingest_event_inner( .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) .collect(); if !imeta_tags.is_empty() { - let tenant_media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage) diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 4664a846e5..7b4b9b785f 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -485,6 +485,7 @@ pub async fn send_channel_message( media_tags: Option>>, emoji_tags: Option>>, mention_tags: Option>>, + link_preview_tags: Option>>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -496,6 +497,8 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); + let link_previews = link_preview_tags.unwrap_or_default(); + let relay_base = crate::relay::relay_api_base_url_with_override(&state); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); let mut resolved_root: Option = None; @@ -540,6 +543,8 @@ pub async fn send_channel_message( &media, &emoji, &mention_refs_only, + &link_previews, + &relay_base, )? } }; @@ -706,6 +711,8 @@ fn build_managed_agent_channel_message( &[], &[], &[], + &[], + &crate::relay::relay_api_base_url(), client_tags, ) } diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index f487c8ce16..23cb2ba220 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -156,14 +156,34 @@ async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() { fn boundary_huddle_stt_blocks_ncryptsec() { let keys = nostr::Keys::generate(); let channel = uuid::Uuid::new_v4(); - let builder = - crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap(); + let builder = crate::events::build_message( + channel, + NCRYPTSEC, + None, + &[], + &[], + &[], + &[], + &[], + &crate::relay::relay_api_base_url(), + ) + .unwrap(); let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err(); assert_guard_error(&err); // Clean transcripts pass through the same seam. - let builder = - crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap(); + let builder = crate::events::build_message( + channel, + "hello huddle", + None, + &[], + &[], + &[], + &[], + &[], + &crate::relay::relay_api_base_url(), + ) + .unwrap(); assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok()); } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 29df7b563d..b7937419bf 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -8,11 +8,9 @@ //! //! Each function validates inputs and returns a nostr::EventBuilder. //! Signing and submission happen in relay::submit_event. - use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; - // ── Constants ──────────────────────────────────────────────────────────────── /// Maximum content size — matches buzz-sdk (64 KiB). @@ -294,6 +292,7 @@ pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + link_preview_tags: &[Vec], + relay_base: &str, ) -> Result { build_message_with_client_tags( channel_id, @@ -311,6 +312,8 @@ pub fn build_message( media_tags, custom_emoji_tags, mention_ref_tags, + link_preview_tags, + relay_base, &[], ) } @@ -329,6 +332,8 @@ pub fn build_message_with_client_tags( media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + link_preview_tags: &[Vec], + relay_base: &str, client_tags: &[Vec], ) -> Result { check_content(content)?; @@ -340,6 +345,7 @@ pub fn build_message_with_client_tags( imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; + crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 9572ac25bf..6884699110 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -647,14 +647,23 @@ pub(crate) fn spawn_transcription_task( .clone(); let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect(); - let builder = - match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) { - Ok(b) => b, - Err(e) => { - eprintln!("buzz-desktop: STT build_message: {e}"); - continue; - } - }; + let builder = match events::build_message( + channel_uuid, + &t, + None, + &p_tags, + &[], + &[], + &[], + &[], + &crate::relay::relay_api_base_url(), + ) { + Ok(b) => b, + Err(e) => { + eprintln!("buzz-desktop: STT build_message: {e}"); + continue; + } + }; // Wait before signing: the relay enforces NIP-98 freshness (±60s) // and the gate may hold for up to MAX_HINT_SECONDS (300s). Sign // the kind event and build NIP-98 auth after the wait so both diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0025a44682..2384a216b4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod huddle; mod identity_storage; mod initial_window; mod key_backup; +mod link_preview_tags; mod linux_media; mod managed_agents; mod media_proxy; @@ -79,7 +80,6 @@ use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] use tray_menu::show_main_window; - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow diff --git a/desktop/src-tauri/src/link_preview_tags.rs b/desktop/src-tauri/src/link_preview_tags.rs new file mode 100644 index 0000000000..e9e21d385f --- /dev/null +++ b/desktop/src-tauri/src/link_preview_tags.rs @@ -0,0 +1,144 @@ +use nostr::Tag; +use std::collections::HashSet; + +const MAX_SNAPSHOTS: usize = 8; +const MAX_TITLE: usize = 300; +const MAX_SITE: usize = 100; +const MAX_DESCRIPTION: usize = 1000; + +fn valid_text(value: &str, max: usize) -> bool { + value.len() <= max && !value.chars().any(char::is_control) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) +} + +fn valid_media_pair(url: &str, hash: &str, relay_base: &url::Url) -> bool { + if url.is_empty() && hash.is_empty() { + return true; + } + if url.is_empty() || !valid_sha256(hash) { + return false; + } + let Ok(parsed) = url::Url::parse(url) else { + return false; + }; + if parsed.origin() != relay_base.origin() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return false; + } + let Some(filename) = parsed.path().strip_prefix("/media/") else { + return false; + }; + if filename.contains('/') || filename.contains('%') { + return false; + } + let Some((path_hash, ext)) = filename.split_once('.') else { + return false; + }; + path_hash == hash && valid_sha256(path_hash) && matches!(ext, "jpg" | "png" | "gif" | "webp") +} + +pub fn append( + preview_tags: &[Vec], + relay_base: &str, + tags: &mut Vec, +) -> Result<(), String> { + if preview_tags.len() > MAX_SNAPSHOTS { + return Err(format!( + "too many link preview snapshots (max {MAX_SNAPSHOTS})" + )); + } + let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL")?; + let mut seen = HashSet::new(); + for preview_tag in preview_tags { + let valid = preview_tag.len() == 11 + && preview_tag[0] == "link-preview" + && preview_tag[1] == "snapshot" + && preview_tag[2] == "1" + && url::Url::parse(&preview_tag[3]).is_ok_and(|url| { + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() + }) + && seen.insert(preview_tag[3].clone()) + && valid_text(&preview_tag[4], MAX_TITLE) + && valid_text(&preview_tag[5], MAX_SITE) + && valid_text(&preview_tag[6], MAX_DESCRIPTION) + && valid_media_pair(&preview_tag[7], &preview_tag[8], &base) + && valid_media_pair(&preview_tag[9], &preview_tag[10], &base); + if !valid { + return Err("invalid link-preview snapshot tag".into()); + } + let parts: Vec<&str> = preview_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid link-preview tag: {e}"))?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BASE: &str = "https://relay.example"; + + fn tag(image_url: &str, image_hash: &str) -> Vec { + [ + "link-preview", + "snapshot", + "1", + "https://linear.app/acme/issue/ABC-123/example", + "Example", + "Linear", + "Description", + image_url, + image_hash, + "", + "", + ] + .map(str::to_string) + .to_vec() + } + + #[test] + fn append_accepts_complete_local_snapshot() { + let mut tags = Vec::new(); + append( + &[tag(&format!("{BASE}/media/{HASH}.png"), HASH)], + BASE, + &mut tags, + ) + .unwrap(); + assert_eq!(tags.len(), 1); + } + + #[test] + fn append_rejects_untrusted_or_malformed_snapshot_media() { + for url in [ + format!("https://evil.example/media/{HASH}.png"), + format!("{BASE}/media/{HASH}.png?token=leak"), + format!("{BASE}/media/{HASH}.png#fragment"), + format!("https://user@relay.example/media/{HASH}.png"), + format!("{BASE}/media/{HASH}.svg"), + format!("{BASE}/media/{HASH}.png/extra"), + ] { + assert!( + append(&[tag(&url, HASH)], BASE, &mut Vec::new()).is_err(), + "{url}" + ); + } + assert!(append( + &[tag(&format!("{BASE}/media/{HASH}.png"), &"b".repeat(64))], + BASE, + &mut Vec::new(), + ) + .is_err()); + } +} diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index ea8dce2afa..8cbbdff2b4 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -124,6 +124,7 @@ export function ForumPostCard({ content={previewContent} messageId={post.eventId} linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)} + linkPreviewTags={post.tags} imetaByUrl={imetaByUrl} mentionNames={mentionNames} mentionPubkeysByName={mentionPubkeysByName} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index a62307b55c..0ad4c6e145 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -118,6 +118,7 @@ function ReplyRow({ content={reply.content} messageId={reply.eventId} linkPreviewsSuppressed={hasLinkPreviewSuppression(reply.tags)} + linkPreviewTags={reply.tags} imetaByUrl={parseImetaTags(reply.tags)} mentionNames={replyMentionNames} mentionPubkeysByName={replyMentionPubkeysByName} @@ -263,6 +264,7 @@ export function ForumThreadPanel({ content={post.content} messageId={post.eventId} linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)} + linkPreviewTags={post.tags} imetaByUrl={parseImetaTags(post.tags)} mentionNames={postMentionNames} mentionPubkeysByName={postMentionPubkeysByName} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..545500bfe4 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -458,6 +458,7 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + linkPreviewTags, } = splitOutgoingTags(mediaTags); const recipientPubkeys = messageMentionPubkeys( effectiveChannel, @@ -468,7 +469,12 @@ export function useSendMessageMutation( // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + linkPreviewTags.length > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), @@ -482,6 +488,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + linkPreviewTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -519,6 +526,7 @@ export function useSendMessageMutation( ...imetaTags, ...emojiTags, ...mentionTags, + ...linkPreviewTags, ], content: content.trim(), sig: "", diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index a2edaa6f8c..79c193fb19 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -662,6 +662,7 @@ test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"]; const EMOJI_A = ["emoji", "shipit", "https://relay/s.png"]; const EMOJI_B = ["emoji", "party", "https://relay/p.gif"]; +const LINK_PREVIEW = ["link-preview", "snapshot", "1", "https://example.com/"]; const MENTION_REF = [ "mention", "1111111111111111111111111111111111111111111111111111111111111111", @@ -672,55 +673,64 @@ test("splitOutgoingTags: undefined input yields three empty arrays", () => { mediaTags: [], emojiTags: [], mentionTags: [], + linkPreviewTags: [], }); }); test("splitOutgoingTags: separates emoji tags from imeta tags", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ - IMETA, - EMOJI_A, - EMOJI_B, - ]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([IMETA, EMOJI_A, EMOJI_B]); assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); test("splitOutgoingTags: emoji-only set leaves mediaTags empty", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([EMOJI_A]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([EMOJI_A]); assert.deepEqual(mediaTags, []); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); test("splitOutgoingTags: separates reference-only mention tags", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ - IMETA, - MENTION_REF, - EMOJI_A, - ]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([IMETA, MENTION_REF, EMOJI_A]); assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, [MENTION_REF]); + assert.deepEqual(linkPreviewTags, []); +}); + +test("splitOutgoingTags: separates authored link-preview snapshots", () => { + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([IMETA, LINK_PREVIEW]); + assert.deepEqual(mediaTags, [IMETA]); + assert.deepEqual(emojiTags, []); + assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, [LINK_PREVIEW]); }); test("splitOutgoingTags: unknown prefixes stay with mediaTags (injection defense)", () => { // A forged ["p", ...] must NOT be misrouted to the emoji channel; it stays on // mediaTags where the server-side imeta guard rejects it. const forged = ["p", "deadbeef"]; - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ - forged, - EMOJI_A, - ]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([forged, EMOJI_A]); assert.deepEqual(mediaTags, [forged]); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); test("splitOutgoingTags is the inverse of mergeOutgoingTags", () => { const merged = mergeOutgoingTags([IMETA], [EMOJI_A, EMOJI_B]); - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags(merged); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags(merged); assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 3e16cb332d..fde8492289 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -359,18 +359,22 @@ export function splitOutgoingTags(tags: string[][] | undefined): { mediaTags: string[][]; emojiTags: string[][]; mentionTags: string[][]; + linkPreviewTags: string[][]; } { const mediaTags: string[][] = []; const emojiTags: string[][] = []; const mentionTags: string[][] = []; + const linkPreviewTags: string[][] = []; for (const tag of tags ?? []) { if (tag[0] === "emoji") { emojiTags.push(tag); } else if (tag[0] === "mention") { mentionTags.push(tag); + } else if (tag[0] === "link-preview") { + linkPreviewTags.push(tag); } else { mediaTags.push(tag); } } - return { mediaTags, emojiTags, mentionTags }; + return { mediaTags, emojiTags, mentionTags, linkPreviewTags }; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6f79daa606..d0ffa93160 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -57,6 +57,7 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; +import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ audienceContext = null, @@ -98,6 +99,11 @@ function MessageComposerImpl({ syncComposerContentFromEditor, syncContentRefFromEditorRef, } = useComposerContentState(); + const [previewContent, setPreviewContent] = React.useState(""); + const { + previewList: composerLinkPreviews, + getReadyTags: getReadyLinkPreviewTags, + } = useComposerLinkPreviews(previewContent); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< @@ -257,6 +263,7 @@ function MessageComposerImpl({ onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, text }) => { setComposerContentFromText(text); + setPreviewContent(text); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); @@ -574,6 +581,7 @@ function MessageComposerImpl({ capturedThreadContext, pendingImeta: currentPendingImeta, queuedAttachments: currentQueuedAttachments, + linkPreviewTags: getReadyLinkPreviewTags(), sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, @@ -594,6 +602,7 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + getReadyLinkPreviewTags, media.clearQueuedAttachments, media.pendingImetaRef, media.queuedAttachmentsRef, @@ -951,6 +960,7 @@ function MessageComposerImpl({ ) : null} + {composerLinkPreviews} {(media.pendingImeta.length > 0 || media.queuedAttachments.length > 0 || media.isUploading) && ( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 7d5b3f9d1b..5c4a9a1642 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -400,6 +400,7 @@ export const MessageRow = React.memo( content={message.body} messageId={message.id} linkPreviewsSuppressed={linkPreviewsSuppressed} + linkPreviewTags={message.tags} onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone} customEmoji={customEmoji} imetaByUrl={imetaByUrl} diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx new file mode 100644 index 0000000000..155cde1053 --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; + +import { uploadMediaBytes } from "@/shared/api/tauri"; +import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; +import { buildLinkPreviewSnapshotTag } from "@/shared/lib/linkPreviewSnapshot"; +import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { LinkPreviewList } from "@/shared/ui/link-preview-list"; +import { LinkPreviewImageLightbox } from "@/shared/ui/markdown"; + +function dataUrlBytes(dataUrl: string): Uint8Array | null { + const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); + if (!match) return null; + try { + return Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0)); + } catch { + return null; + } +} + +async function uploadDataUrl( + dataUrl: string | null | undefined, + filename: string, +) { + if (!dataUrl) return { url: "", sha256: "" }; + const bytes = dataUrlBytes(dataUrl); + if (!bytes) throw new Error("invalid preview media data"); + const uploaded = await uploadMediaBytes([...bytes], filename); + return { url: uploaded.url, sha256: uploaded.sha256 }; +} + +export function useComposerLinkPreviews(content: string) { + const candidates = React.useMemo( + () => extractSupportedLinkPreviews(content), + [content], + ); + const previews = useResolvedLinkPreviews(candidates); + const [readyTags, setReadyTags] = React.useState>( + {}, + ); + const readyTagsRef = React.useRef([]); + const uploadsRef = React.useRef(new Set()); + + React.useEffect(() => { + const active = new Set(candidates.map((preview) => preview.href)); + setReadyTags((current) => + Object.fromEntries( + Object.entries(current).filter(([href]) => active.has(href)), + ), + ); + }, [candidates]); + + React.useEffect(() => { + let cancelled = false; + for (const preview of previews) { + if ( + !preview.snapshotReady || + readyTags[preview.href] || + uploadsRef.current.has(preview.href) + ) + continue; + uploadsRef.current.add(preview.href); + void Promise.all([ + uploadDataUrl(preview.imageDataUrl, "link-preview-image.png"), + uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png"), + ]) + .then(([image, favicon]) => { + if (cancelled) return; + const tag = buildLinkPreviewSnapshotTag({ + canonicalUrl: preview.href, + title: preview.title, + siteName: preview.provider, + description: preview.description ?? "", + imageUrl: image.url, + imageSha256: image.sha256, + faviconUrl: favicon.url, + faviconSha256: favicon.sha256, + }); + setReadyTags((current) => ({ ...current, [preview.href]: tag })); + }) + .catch(() => {}) + .finally(() => uploadsRef.current.delete(preview.href)); + } + return () => { + cancelled = true; + }; + }, [previews, readyTags]); + + readyTagsRef.current = candidates.flatMap((candidate) => + readyTags[candidate.href] ? [readyTags[candidate.href]] : [], + ); + const previewList = previews.length ? ( +
+ +
+ ) : null; + const getReadyTags = React.useCallback(() => readyTagsRef.current, []); + return { previewList, getReadyTags }; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 76503bfab1..b2eb3893b7 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -36,6 +36,7 @@ export type SendMessageWithMentionFlowInput = { capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; queuedAttachments?: QueuedMediaAttachment[]; + linkPreviewTags?: string[][]; sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6ba9f69050..a54df4ba3d 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -726,6 +726,7 @@ export function useMentionSendFlow({ capturedThreadContext = null, pendingImeta, queuedAttachments = [], + linkPreviewTags = [], sentDraftKey, recoveryDraftKey, spoileredAttachmentUrls = new Set(), @@ -788,7 +789,10 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji); + const outgoingTags = [ + ...buildCustomEmojiTags(trimmed, customEmoji), + ...linkPreviewTags, + ]; const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 72980363f5..b2e47b5364 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -550,6 +550,7 @@ export async function sendChannelMessage( kind?: number, emojiTags?: string[][], mentionTags?: string[][], + linkPreviewTags?: string[][], ): Promise { const response = await invokeTauri( "send_channel_message", @@ -560,6 +561,7 @@ export async function sendChannelMessage( mediaTags: mediaTags ?? null, emojiTags: emojiTags ?? null, mentionTags: mentionTags ?? null, + linkPreviewTags, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, }, diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs new file mode 100644 index 0000000000..a9c9f7724d --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseLinkPreviewSnapshots } from "./linkPreviewSnapshot.ts"; + +const HASH = "a".repeat(64); +const ORIGIN = "https://relay.example"; +const CONTENT = "Read https://linear.app/acme/issue/ABC-123/example"; +const URL = "https://linear.app/acme/issue/ABC-123/example"; +const valid = [ + "link-preview", + "snapshot", + "1", + URL, + "Example", + "Linear", + "Description", + `${ORIGIN}/media/${HASH}.png`, + HASH, + "", + "", +]; + +test("authored snapshots render only exact local media tied to message content", () => { + assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, ORIGIN).length, 1); + assert.equal( + parseLinkPreviewSnapshots([valid], "no matching link", ORIGIN).length, + 0, + ); + assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, null).length, 0); +}); + +test("authored snapshots reject remote, malformed, credentialed, and hash-mismatched media", () => { + for (const image of [ + `https://evil.example/media/${HASH}.png`, + `${ORIGIN}/media/${HASH}.png?token=leak`, + `${ORIGIN}/media/${HASH}.png#fragment`, + `https://user@relay.example/media/${HASH}.png`, + `${ORIGIN}/media/${HASH}.svg`, + `${ORIGIN}/media/${HASH}.png/extra`, + ]) { + const tag = [...valid]; + tag[7] = image; + assert.equal( + parseLinkPreviewSnapshots([tag], CONTENT, ORIGIN).length, + 0, + image, + ); + } + const mismatch = [...valid]; + mismatch[8] = "b".repeat(64); + assert.equal( + parseLinkPreviewSnapshots([mismatch], CONTENT, ORIGIN).length, + 0, + ); +}); + +test("messages without authored snapshots never create recipient previews", () => { + assert.deepEqual(parseLinkPreviewSnapshots([], CONTENT, ORIGIN), []); + assert.deepEqual(parseLinkPreviewSnapshots(undefined, CONTENT, ORIGIN), []); +}); diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.ts b/desktop/src/shared/lib/linkPreviewSnapshot.ts new file mode 100644 index 0000000000..7bac796b72 --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewSnapshot.ts @@ -0,0 +1,143 @@ +import { + extractSupportedLinkPreviews, + parseSupportedLinkPreview, +} from "./linkPreview"; +import type { ResolvedLinkPreview } from "./useResolvedLinkPreviews"; + +export const LINK_PREVIEW_SNAPSHOT_VERSION = "1"; +const MAX_SNAPSHOTS = 8; +const SHA256_RE = /^[0-9a-f]{64}$/; +const IMAGE_EXT_RE = /^(?:jpg|png|gif|webp)$/; + +export type LinkPreviewSnapshot = { + canonicalUrl: string; + title: string; + siteName: string; + description: string; + imageUrl: string; + imageSha256: string; + faviconUrl: string; + faviconSha256: string; +}; + +function validText(value: string, max: number): boolean { + return ( + value.length <= max && + !Array.from(value).some((char) => { + const code = char.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }) + ); +} + +function isRelayMediaPair( + url: string, + sha256: string, + relayOrigin: string, +): boolean { + if (!url && !sha256) return true; + if (!url || !SHA256_RE.test(sha256)) return false; + try { + const parsed = new URL(url); + if ( + parsed.origin !== relayOrigin || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) + return false; + const match = /^\/media\/([0-9a-f]{64})\.([a-z0-9]{1,8})$/.exec( + parsed.pathname, + ); + return Boolean( + match && match[1] === sha256 && IMAGE_EXT_RE.test(match[2] ?? ""), + ); + } catch { + return false; + } +} + +export function parseLinkPreviewSnapshots( + tags: readonly (readonly string[])[] | undefined, + content: string, + relayOrigin: string | null, +): ResolvedLinkPreview[] { + if (!relayOrigin || !tags) return []; + const contentUrls = new Set( + extractSupportedLinkPreviews(content).map((preview) => preview.href), + ); + const seen = new Set(); + const snapshots: ResolvedLinkPreview[] = []; + for (const tag of tags) { + if (tag[0] !== "link-preview" || tag[1] !== "snapshot") continue; + if ( + snapshots.length >= MAX_SNAPSHOTS || + tag.length !== 11 || + tag[2] !== LINK_PREVIEW_SNAPSHOT_VERSION + ) + continue; + const [ + , + , + , + canonicalUrl, + title, + siteName, + description, + imageUrl, + imageSha256, + faviconUrl, + faviconSha256, + ] = tag; + const parsed = parseSupportedLinkPreview(canonicalUrl); + if ( + !parsed || + parsed.href !== canonicalUrl || + !contentUrls.has(canonicalUrl) || + seen.has(canonicalUrl) + ) + continue; + if ( + !validText(title, 300) || + !validText(siteName, 100) || + !validText(description, 1000) + ) + continue; + if ( + !isRelayMediaPair(imageUrl, imageSha256, relayOrigin) || + !isRelayMediaPair(faviconUrl, faviconSha256, relayOrigin) + ) + continue; + seen.add(canonicalUrl); + snapshots.push({ + ...parsed, + title: title || parsed.title, + provider: siteName || parsed.provider, + description: description || null, + faviconDataUrl: faviconUrl || null, + imageDataUrl: imageUrl || null, + imageDomain: imageUrl ? new URL(imageUrl).hostname : null, + imageState: imageUrl ? "image" : "none", + }); + } + return snapshots; +} + +export function buildLinkPreviewSnapshotTag( + snapshot: LinkPreviewSnapshot, +): string[] { + return [ + "link-preview", + "snapshot", + LINK_PREVIEW_SNAPSHOT_VERSION, + snapshot.canonicalUrl, + snapshot.title, + snapshot.siteName, + snapshot.description, + snapshot.imageUrl, + snapshot.imageSha256, + snapshot.faviconUrl, + snapshot.faviconSha256, + ]; +} diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index cd79864c7d..f8404fc35a 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -10,7 +10,7 @@ type LinkPreviewImageFetchState = | "transient_failure" | "rejected"; -type LinkPreviewMetadata = { +export type LinkPreviewMetadata = { title: string; siteName: string | null; description: string | null; @@ -179,6 +179,8 @@ export type ResolvedLinkPreview = SupportedLinkPreview & { description?: string | null; faviconDataUrl?: string | null; imageState: LinkPreviewImageState; + /** Metadata extraction completed successfully; safe to snapshot after media uploads. */ + snapshotReady?: boolean; }; type ResolvedMetadataByHref = Record< @@ -207,6 +209,7 @@ export function resolveLinkPreview( : "none"; return { ...preview, + snapshotReady: true, title: metadata.title, description: metadata.description, faviconDataUrl: metadata.faviconDataUrl, diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 1a3718b8c3..6411bc5b59 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -24,11 +24,9 @@ import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; -import { - extractSupportedLinkPreviews, - parseSupportedLinkPreview, -} from "@/shared/lib/linkPreview"; -import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; @@ -975,7 +973,7 @@ function ImageZoomOverlay({ ); } -const LinkPreviewImageLightbox = +export const LinkPreviewImageLightbox = createLinkPreviewImageLightbox(ImageZoomOverlay); /** @@ -1822,6 +1820,7 @@ function MarkdownInner({ mediaInset = false, messageId, linkPreviewsSuppressed = false, + linkPreviewTags, onRemoveLinkPreviewsForEveryone, mentionNames, mentionPubkeysByName, @@ -1854,13 +1853,19 @@ function MarkdownInner({ }, [goChannel], ); - const previewsGloballySuppressed = linkPreviewsSuppressed; - const linkPreviews = React.useMemo( + const relayOrigin = useRelayOrigin(); + const resolvedLinkPreviews = React.useMemo( () => - interactive && !previewsGloballySuppressed - ? extractSupportedLinkPreviews(content) + interactive && !linkPreviewsSuppressed + ? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin) : [], - [content, interactive, previewsGloballySuppressed], + [ + content, + interactive, + linkPreviewTags, + linkPreviewsSuppressed, + relayOrigin, + ], ); const configNudge = React.useMemo( () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), @@ -1905,8 +1910,6 @@ function MarkdownInner({ processedContent = `${processedContent}\u200B`; } - const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews); - // When a config-nudge suppresses the prose (selectProseOrNudge returns // null), skip the parse entirely — it would be thrown away unrendered. const componentSet = getMarkdownComponents(interactive, mediaInset); @@ -1977,6 +1980,7 @@ export const Markdown = React.memo( prev.mediaInset === next.mediaInset && prev.messageId === next.messageId && prev.linkPreviewsSuppressed === next.linkPreviewsSuppressed && + prev.linkPreviewTags === next.linkPreviewTags && prev.onRemoveLinkPreviewsForEveryone === next.onRemoveLinkPreviewsForEveryone && shallowRecordEqual( diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index cb31ad5b8e..e0c158a782 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -62,6 +62,7 @@ export type MarkdownProps = { /** Event/message identity used only for local preview-image visibility. */ messageId?: string; linkPreviewsSuppressed?: boolean; + linkPreviewTags?: readonly (readonly string[])[]; onRemoveLinkPreviewsForEveryone?: () => Promise; searchQuery?: string; /** Display name shown in shared-agent card metadata. */