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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 59 additions & 7 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool, String> {
// 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<Vec<u8>, 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
Expand Down Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion desktop/src/features/forum/ui/ForumPostCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MessageSquare } from "lucide-react";
import { useMemo } from "react";

import {
resolveUserLabel,
Expand Down Expand Up @@ -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 <button> it renders) is rebuilt on
// every ForumPostCard render, swapping the live DOM node. A click that lands
// across one of those swaps splits mousedown/mouseup onto different nodes, so
// the browser never fires `click` and a file download is silently dropped.
const imetaByUrl = useMemo(() => parseImetaTags(post.tags), [post.tags]);
const summary = post.threadSummary;
const previewContent =
post.content.length > 200
Expand Down Expand Up @@ -110,7 +118,7 @@ export function ForumPostCard({
<Markdown
compact
content={previewContent}
imetaByUrl={parseImetaTags(post.tags)}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
/>
</div>
Expand Down
63 changes: 47 additions & 16 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,23 +155,37 @@ function ImageContextMenu({
React.useEffect(() => {
if (!menu) return;
const close = () => setMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
window.addEventListener("scroll", close, true);
// Defer attaching the dismiss listeners until after the current event
// loop turn. The right-click that opens the menu (a `contextmenu` on
// mousedown) is often followed by a trailing `click`/`pointerup` on the
// same interaction; attaching synchronously lets that trailing event —
// and the platform `click` some webviews emit on right-button release —
// immediately dismiss the menu, so it only flashes. Deferring guarantees
// the opening interaction can never be the one that closes it.
let attached = false;
const timer = window.setTimeout(() => {
attached = true;
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
window.addEventListener("scroll", close, true);
}, 0);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
window.removeEventListener("scroll", close, true);
window.clearTimeout(timer);
if (attached) {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
window.removeEventListener("scroll", close, true);
}
};
}, [menu]);

return (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: context menu handler on image wrapper */}
<div
onContextMenu={(e) => {
onContextMenuCapture={(e) => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setMenu({ x: e.clientX, y: e.clientY });
}}
>
Expand Down Expand Up @@ -300,8 +314,13 @@ function formatFileSize(bytes: number): string {

/**
* File card for a generic (non-image, non-video) attachment: icon, filename,
* size, and a download action. The blob is served with
* `Content-Disposition: attachment`, so following the link downloads it.
* size, and a download action.
*
* Downloads go through the native `download_file` Tauri command (HTTP inside
* the app's tunnel + a save dialog), not a plain `<a download>` link. A bare
* link navigates the webview to the blob URL, which escapes to the OS browser
* and gets bounced to a corporate CDN interstitial ("browser not supported").
* The native command mirrors the image-download path.
*/
function FileCard({
href,
Expand All @@ -314,11 +333,18 @@ function FileCard({
}) {
const sizeLabel = size != null ? formatFileSize(size) : "";
return (
<a
href={href}
download={filename}
<button
type="button"
onClick={() => {
invokeTauri("download_file", { url: href, filename }).catch(
(err: unknown) => {
const msg = err instanceof Error ? err.message : "Download failed";
toast.error(msg);
},
);
}}
data-testid="file-card"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-xl border border-border/70 bg-muted/40 px-3 py-2 no-underline transition-colors hover:bg-muted/70"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-background text-muted-foreground">
<FileText className="h-5 w-5" />
Expand All @@ -334,7 +360,7 @@ function FileCard({
) : null}
</span>
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
</a>
</button>
);
}

Expand Down Expand Up @@ -647,7 +673,12 @@ function createMarkdownComponents(
<ImageContextMenu src={src}>
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="mt-1 max-w-sm cursor-pointer">
<div
className="mt-1 max-w-sm cursor-pointer"
onPointerDown={(e) => {
if (e.button !== 0) e.preventDefault();
}}
>
<img
alt={alt}
className="max-h-64 max-w-full rounded-xl object-contain"
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5491,6 +5491,12 @@ export function maybeInstallE2eTauriMocks() {
return resolveMockUploadDescriptors(activeConfig);
case "upload_media_bytes":
return resolveMockUploadDescriptors(activeConfig)[0];
case "download_image":
case "download_file":
// The save dialog can't run headlessly; report a successful save so the
// FileCard / image-menu click handlers resolve. Specs assert the
// command was invoked via `__SPROUT_E2E_COMMANDS__`, not the dialog.
return true;
case "get_event":
return handleGetEvent(
payload as Parameters<typeof handleGetEvent>[0],
Expand Down
42 changes: 28 additions & 14 deletions desktop/tests/e2e/file-attachment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a download>` 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 ({
Expand All @@ -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");
});