diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index b6456235b..f968ff873 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -5,7 +5,7 @@ use crate::app_state::AppState; use crate::commands::export_util::save_bytes_with_dialog; use crate::relay::relay_api_base_url_with_override; -use super::media::detect_and_validate_mime; +use super::media::{detect_and_validate_mime, sanitize_filename}; /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; @@ -78,10 +78,65 @@ pub async fn download_image( .unwrap_or("png") .to_string(); - // Fetch image bytes via the app's HTTP client (goes through WARP tunnel). + let bytes = fetch_blob_bytes(&url, &state).await?; + + // Validate the downloaded content is actually a supported media type. + detect_and_validate_mime(&bytes)?; + + save_bytes_with_dialog(&app, &filename, "Images", &[&ext], &bytes).await +} + +/// Download an arbitrary file attachment from a relay `/media/` URL and save it +/// via a native save-file dialog. +/// +/// The frontend supplies `filename` from the message's imeta `filename` field +/// (the URL path is only the content hash, so it carries no human-readable +/// name). We sanitize it defensively before using it as the suggested name. +/// +/// Mirrors `download_image`'s SSRF and size protections, but uses a generic +/// "All Files" dialog filter and derives the extension from the supplied +/// filename rather than assuming an image. +#[tauri::command] +pub async fn download_file( + url: String, + filename: String, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + // SSRF protection: only allow downloads from the relay's /media/ path. + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + // The imeta filename is the only human-readable name we have; sanitize it + // so directory traversal / control characters can never reach the dialog. + let filename = sanitize_filename(&filename); + + // Derive extension for the save dialog filter from the supplied filename. + let ext = std::path::Path::new(&filename) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_string()); + + let bytes = fetch_blob_bytes(&url, &state).await?; + + // Reuse the upload-side allow/deny policy: rejects executables, HTML, and + // other types the relay would never have accepted, while permitting the + // arbitrary `application/octet-stream` / text payloads that uploads allow. + detect_and_validate_mime(&bytes)?; + + // Generic filter: an arbitrary attachment is not necessarily an image. + let extensions: Vec<&str> = ext.as_deref().into_iter().collect(); + save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await +} + +/// Fetch blob bytes from a (pre-validated) relay media URL through the app's +/// HTTP client, enforcing the download size cap. The caller is responsible for +/// validating the URL origin and for any content-type checks on the result. +async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result, String> { + // Fetch bytes via the app's HTTP client (goes through WARP tunnel). let resp = state .http_client - .get(&url) + .get(url) .timeout(DOWNLOAD_TIMEOUT) .send() .await @@ -119,10 +174,7 @@ pub async fn download_image( bytes.extend_from_slice(&chunk); } - // Validate the downloaded content is actually a supported media type. - detect_and_validate_mime(&bytes)?; - - save_bytes_with_dialog(&app, &filename, "Images", &[&ext], &bytes).await + Ok(bytes) } #[cfg(test)] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 97b6e161c..82f28ab5b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -633,6 +633,7 @@ pub fn run() { pick_and_upload_media, upload_media_bytes, download_image, + download_file, list_relay_members, get_my_relay_membership, add_relay_member, diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 991d6c290..92633a0d1 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -1,4 +1,5 @@ import { MessageSquare } from "lucide-react"; +import { useMemo } from "react"; import { resolveUserLabel, @@ -44,6 +45,13 @@ export function ForumPostCard({ }); const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null; const mentionNames = resolveMentionNames(post.tags, profiles); + // Memoize the imeta map: `parseImetaTags` builds a fresh object each render, + // and the `Markdown` memo compares `imetaByUrl` by reference. Without this, + // the post's Markdown (and the FileCard ); } @@ -647,7 +673,12 @@ function createMarkdownComponents( -
+
{ + if (e.button !== 0) e.preventDefault(); + }} + > {alt}[0], diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 8c54dc7bb..c8e2968c9 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -39,16 +39,24 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => { // Send the (attachment-only) message. await page.getByTestId("send-message").click(); - // A FileCard renders in the timeline: a download link carrying the filename - // and pointing at the blob URL. + // A FileCard renders in the timeline: a button carrying the filename. It + // downloads via the native `download_file` command (HTTP inside the app's + // tunnel + save dialog), NOT a plain `` link — a bare link + // escapes the webview to the OS browser and hits a corporate CDN page. const card = page.getByTestId("file-card"); await expect(card).toBeVisible(); await expect(card).toContainText("quarterly-report.pdf"); - await expect(card).toHaveAttribute( - "href", - `https://mock.relay/media/${"a".repeat(64)}.pdf`, - ); - await expect(card).toHaveAttribute("download", "quarterly-report.pdf"); + + await card.click(); + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __SPROUT_E2E_COMMANDS__?: string[] }) + .__SPROUT_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("download_file"); }); test("forum posts emit a FileCard for generic attachments, not a broken image", async ({ @@ -75,14 +83,20 @@ test("forum posts emit a FileCard for generic attachments, not a broken image", await page.getByTestId("send-message").click(); // The post renders through the shared Markdown component as a FileCard — - // a download link carrying the filename and pointing at the blob URL — NOT - // an inline image. + // a button carrying the filename that downloads via the native + // `download_file` command — NOT an inline image and NOT a bare link. const card = page.getByTestId("file-card"); await expect(card).toBeVisible(); await expect(card).toContainText("quarterly-report.pdf"); - await expect(card).toHaveAttribute( - "href", - `https://mock.relay/media/${"a".repeat(64)}.pdf`, - ); - await expect(card).toHaveAttribute("download", "quarterly-report.pdf"); + + await card.click(); + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __SPROUT_E2E_COMMANDS__?: string[] }) + .__SPROUT_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("download_file"); });