mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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)]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user