fix: native arbitrary-file download + image context-menu flash (#830)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-03 09:59:57 -07:00
committed by GitHub
co-authored by Brain
parent 7797ae77f6
commit 82ae85f79a
6 changed files with 150 additions and 38 deletions
@@ -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<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
@@ -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)]
+1
View File
@@ -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,
@@ -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 <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
@@ -110,7 +118,7 @@ export function ForumPostCard({
<Markdown
compact
content={previewContent}
imetaByUrl={parseImetaTags(post.tags)}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
/>
</div>
+47 -16
View File
@@ -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 });
}}
>
@@ -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,
@@ -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" />
@@ -334,7 +360,7 @@ function FileCard({
) : null}
</span>
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
</a>
</button>
);
}
@@ -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"
+6
View File
@@ -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],
+28 -14
View File
@@ -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 ({
@@ -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");
});