mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00: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::commands::export_util::save_bytes_with_dialog;
|
||||||
use crate::relay::relay_api_base_url_with_override;
|
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.
|
/// Maximum download size: 50 MiB. Prevents OOM from oversized responses.
|
||||||
const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;
|
const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||||
@@ -78,10 +78,65 @@ pub async fn download_image(
|
|||||||
.unwrap_or("png")
|
.unwrap_or("png")
|
||||||
.to_string();
|
.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
|
let resp = state
|
||||||
.http_client
|
.http_client
|
||||||
.get(&url)
|
.get(url)
|
||||||
.timeout(DOWNLOAD_TIMEOUT)
|
.timeout(DOWNLOAD_TIMEOUT)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -119,10 +174,7 @@ pub async fn download_image(
|
|||||||
bytes.extend_from_slice(&chunk);
|
bytes.extend_from_slice(&chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the downloaded content is actually a supported media type.
|
Ok(bytes)
|
||||||
detect_and_validate_mime(&bytes)?;
|
|
||||||
|
|
||||||
save_bytes_with_dialog(&app, &filename, "Images", &[&ext], &bytes).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -633,6 +633,7 @@ pub fn run() {
|
|||||||
pick_and_upload_media,
|
pick_and_upload_media,
|
||||||
upload_media_bytes,
|
upload_media_bytes,
|
||||||
download_image,
|
download_image,
|
||||||
|
download_file,
|
||||||
list_relay_members,
|
list_relay_members,
|
||||||
get_my_relay_membership,
|
get_my_relay_membership,
|
||||||
add_relay_member,
|
add_relay_member,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MessageSquare } from "lucide-react";
|
import { MessageSquare } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveUserLabel,
|
resolveUserLabel,
|
||||||
@@ -44,6 +45,13 @@ export function ForumPostCard({
|
|||||||
});
|
});
|
||||||
const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||||
const mentionNames = resolveMentionNames(post.tags, profiles);
|
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 summary = post.threadSummary;
|
||||||
const previewContent =
|
const previewContent =
|
||||||
post.content.length > 200
|
post.content.length > 200
|
||||||
@@ -110,7 +118,7 @@ export function ForumPostCard({
|
|||||||
<Markdown
|
<Markdown
|
||||||
compact
|
compact
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
imetaByUrl={parseImetaTags(post.tags)}
|
imetaByUrl={imetaByUrl}
|
||||||
mentionNames={mentionNames}
|
mentionNames={mentionNames}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,23 +155,37 @@ function ImageContextMenu({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => setMenu(null);
|
const close = () => setMenu(null);
|
||||||
window.addEventListener("click", close);
|
// Defer attaching the dismiss listeners until after the current event
|
||||||
window.addEventListener("contextmenu", close);
|
// loop turn. The right-click that opens the menu (a `contextmenu` on
|
||||||
window.addEventListener("scroll", close, true);
|
// 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 () => {
|
return () => {
|
||||||
window.removeEventListener("click", close);
|
window.clearTimeout(timer);
|
||||||
window.removeEventListener("contextmenu", close);
|
if (attached) {
|
||||||
window.removeEventListener("scroll", close, true);
|
window.removeEventListener("click", close);
|
||||||
|
window.removeEventListener("contextmenu", close);
|
||||||
|
window.removeEventListener("scroll", close, true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [menu]);
|
}, [menu]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: context menu handler on image wrapper */}
|
|
||||||
<div
|
<div
|
||||||
onContextMenu={(e) => {
|
onContextMenuCapture={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
setMenu({ x: e.clientX, y: e.clientY });
|
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,
|
* File card for a generic (non-image, non-video) attachment: icon, filename,
|
||||||
* size, and a download action. The blob is served with
|
* size, and a download action.
|
||||||
* `Content-Disposition: attachment`, so following the link downloads it.
|
*
|
||||||
|
* 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({
|
function FileCard({
|
||||||
href,
|
href,
|
||||||
@@ -314,11 +333,18 @@ function FileCard({
|
|||||||
}) {
|
}) {
|
||||||
const sizeLabel = size != null ? formatFileSize(size) : "";
|
const sizeLabel = size != null ? formatFileSize(size) : "";
|
||||||
return (
|
return (
|
||||||
<a
|
<button
|
||||||
href={href}
|
type="button"
|
||||||
download={filename}
|
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"
|
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">
|
<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" />
|
<FileText className="h-5 w-5" />
|
||||||
@@ -334,7 +360,7 @@ function FileCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,7 +673,12 @@ function createMarkdownComponents(
|
|||||||
<ImageContextMenu src={src}>
|
<ImageContextMenu src={src}>
|
||||||
<DialogPrimitive.Root>
|
<DialogPrimitive.Root>
|
||||||
<DialogPrimitive.Trigger asChild>
|
<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
|
<img
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className="max-h-64 max-w-full rounded-xl object-contain"
|
className="max-h-64 max-w-full rounded-xl object-contain"
|
||||||
|
|||||||
@@ -5491,6 +5491,12 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
return resolveMockUploadDescriptors(activeConfig);
|
return resolveMockUploadDescriptors(activeConfig);
|
||||||
case "upload_media_bytes":
|
case "upload_media_bytes":
|
||||||
return resolveMockUploadDescriptors(activeConfig)[0];
|
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":
|
case "get_event":
|
||||||
return handleGetEvent(
|
return handleGetEvent(
|
||||||
payload as Parameters<typeof handleGetEvent>[0],
|
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.
|
// Send the (attachment-only) message.
|
||||||
await page.getByTestId("send-message").click();
|
await page.getByTestId("send-message").click();
|
||||||
|
|
||||||
// A FileCard renders in the timeline: a download link carrying the filename
|
// A FileCard renders in the timeline: a button carrying the filename. It
|
||||||
// and pointing at the blob URL.
|
// 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");
|
const card = page.getByTestId("file-card");
|
||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
await expect(card).toContainText("quarterly-report.pdf");
|
await expect(card).toContainText("quarterly-report.pdf");
|
||||||
await expect(card).toHaveAttribute(
|
|
||||||
"href",
|
await card.click();
|
||||||
`https://mock.relay/media/${"a".repeat(64)}.pdf`,
|
await expect
|
||||||
);
|
.poll(() =>
|
||||||
await expect(card).toHaveAttribute("download", "quarterly-report.pdf");
|
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 ({
|
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();
|
await page.getByTestId("send-message").click();
|
||||||
|
|
||||||
// The post renders through the shared Markdown component as a FileCard —
|
// The post renders through the shared Markdown component as a FileCard —
|
||||||
// a download link carrying the filename and pointing at the blob URL — NOT
|
// a button carrying the filename that downloads via the native
|
||||||
// an inline image.
|
// `download_file` command — NOT an inline image and NOT a bare link.
|
||||||
const card = page.getByTestId("file-card");
|
const card = page.getByTestId("file-card");
|
||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
await expect(card).toContainText("quarterly-report.pdf");
|
await expect(card).toContainText("quarterly-report.pdf");
|
||||||
await expect(card).toHaveAttribute(
|
|
||||||
"href",
|
await card.click();
|
||||||
`https://mock.relay/media/${"a".repeat(64)}.pdf`,
|
await expect
|
||||||
);
|
.poll(() =>
|
||||||
await expect(card).toHaveAttribute("download", "quarterly-report.pdf");
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as Window & { __SPROUT_E2E_COMMANDS__?: string[] })
|
||||||
|
.__SPROUT_E2E_COMMANDS__ ?? [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toContain("download_file");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user