mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
Buzz renders inside a native webview whose default context menu has no useful link or media actions, so right-clicking a link was a no-op (#02ef9f1b) and videos had no Download at all. This adds custom in-app context menus for links and inline videos, and dedupes the existing image menu into a single shared primitive. - New `MediaContextMenu` primitive (portal menu + deferred-dismiss hook), reused three ways: image (Copy/Download), link (Open/Copy), video (Download/Copy). Each variant emits a distinct data attribute (`data-image-context-menu` / `data-link-context-menu` / `data-video-context-menu`) plus the shared `data-media-context-menu` marker, so e2e locators target one surface without aliasing another; the image variant additionally carries `data-image-lightbox-controls` for the in-lightbox dismiss guard. - Links: `ExternalLinkAnchor` adds Open link (OS opener) / Copy link. - Videos: the `useVideoContextMenu` hook owns the inline right-click menu's state and actions — Download video (reuses the existing `download_file` command) and Copy link — keeping that logic out of the large `VideoPlayer` and out of the pure `videoDownload.ts` helpers. Render classification and download eligibility are independent (pure, DOM-free helpers in `markdown/mediaEntry.ts`): - `isVideoMedia` classifies from the imeta MIME (`video/*`), authoritative when present, with a legacy path-extension fallback for pre-MIME events. Previously only `.mp4` was recognized, so relay `.webm`/`.mov` fell into the image renderer and Tyler's video Download was absent. The relay-media proxy regex is widened to `mp4|webm|mov` to match. - `isRelayDownloadable` gates Download on relay `/media/` origin, not mere URL presence, so an external video renders and offers Copy link but omits Download (which the Rust SSRF gate would reject anyway). The original relay `src` is the download URL, never the rewritten proxy `resolvedSrc`. Download eligibility is reactive and fails closed. The relay origin resolves asynchronously and is commonly still null on first render; a synchronous read would freeze eligibility, so `mediaUrl.ts` exposes the origin as an observable (`subscribeRelayOrigin` + `getCachedRelayOrigin`) consumed via `useRelayOrigin()` (`useSyncExternalStore`). `MarkdownVideoPlayer` reads it, so eligibility recomputes when the origin resolves and on workspace switch. `isRelayDownloadable` now returns false for an unresolved origin (fail closed) rather than optimistically true — before, an off-relay `/media/` URL wrongly showed Download until resolution. A generation token (`beginRelayOriginFetch`) ensures an in-flight origin fetch from a previous community can never publish after a workspace switch. The origin fetch is retried inside the port poll loop (a single fire-and-forget attempt could fail before the Tauri bridge was ready and leave the origin null forever), and each poll invoke is bounded by the remaining deadline (`withDeadline`) so a never-settling IPC call cannot hang startup past the 5s budget. Redirect-hop SSRF: authenticated media *download* fetches use a dedicated no-redirect client built by `build_media_fetch_client() -> Result` (`redirect::Policy::none()`). `build_app_state()` calls `.expect(...)` so a build failure panics loudly at startup rather than silently degrading to a redirect-following client (fail closed, not fail open). A relay 3xx is returned verbatim and mapped by `redirect_refusal_error` to an actionable "redirect refused" error — not a generic relay failure and never a followed cross-origin fetch of the minted media Authorization header. The app-wide `http_client` is unchanged. Follow-up (NOT fixed here — this PR hardens the download path only, and must not be read as making rendering redirect-safe): the media *rendering* path shares the same exposure. The axum proxy in `src-tauri/src/media_proxy.rs` and the `buzz-media://` protocol handler (registered in `src-tauri/src/lib.rs`) both mint a media Authorization header and follow redirects, so a relay 3xx to a private/off-origin target would forward that header cross-origin. It is left untouched here because it is the passive streaming path (a legitimate relay CDN-offload redirect would break `<video>`/`<img>`), it is broader than this Download lane, and scope was deliberately kept narrow. Tests: `mediaEntry.test.mjs` (MIME/extension classification incl. uppercase, query strings, MIME-with-extensionless URL; relay/off-relay/malformed download eligibility; fail-closed unresolved origin + both resolution transitions); `mediaUrl.test.mjs` (observable store: publish/notify, notify-only-on-change, per-listener unsubscribe, the A->reset->B late-A race, transient-failure retry publishes exactly once, reset-mid-retry discards the stale generation, and `withDeadline` incl. a never-settling invoke, an early rejection, and a late rejection after timeout staying observed); `videoDownload.test.mjs` (save-dialog filename fallback); Rust `validate_download_url` mp4/webm parity and private-host rejection, a loopback no-redirect test proving a 302 is not followed, exactly one request is issued, and the command-facing error identifies the refused redirect, plus `build_media_fetch_client` constructs with the no-redirect policy and `redirect_refusal_error` only fires for 3xx; e2e distinct-selector probes for link (Open/Copy via a genuine bubbling contextmenu event), relay video (Download appears once the origin resolves), and off-relay video (Copy only, never Download), each asserting no cross-variant attributes. 3031 desktop unit tests, whole-package `cargo test -p buzz-desktop` (1445 lib + 3 integration), typecheck, lint, and `pnpm build` all clean. Note: a real authenticated saved-byte download check remains for the integrated Desktop tip (can't drive a live relay from this lane). Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import * as React from "react";
|
|
|
|
import { invokeTauri } from "@/shared/api/tauri";
|
|
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
|
import {
|
|
MediaContextMenu,
|
|
type MediaContextMenuItem,
|
|
type MediaContextMenuPosition,
|
|
useDismissMediaContextMenu,
|
|
} from "@/shared/ui/markdown/MediaContextMenu";
|
|
import { resolveVideoDownloadFilename } from "@/shared/ui/videoDownload";
|
|
import { toast } from "sonner";
|
|
|
|
type UseVideoContextMenu = {
|
|
/** `onContextMenuCapture` handler for the inline video surface. */
|
|
onContextMenu: (event: React.MouseEvent) => void;
|
|
/** The positioned menu element while open, or `null`. */
|
|
menu: React.ReactNode;
|
|
};
|
|
|
|
/**
|
|
* Owns the inline video right-click menu: open/close state, the pointer-anchor
|
|
* handler, and the Download/Copy actions. Kept out of `VideoPlayer` so that
|
|
* large component stays focused on playback, and out of the pure
|
|
* `videoDownload.ts` helpers so they keep their DOM-free, Node-testable shape.
|
|
*
|
|
* `downloadUrl` is the original relay `/media/` URL (distinct from a rewritten
|
|
* proxy `src`); when absent the menu omits Download and offers only Copy link,
|
|
* so a non-relay video — which the download command's SSRF gate would reject —
|
|
* never surfaces an action that could only error.
|
|
*/
|
|
export function useVideoContextMenu(
|
|
src: string,
|
|
downloadUrl?: string,
|
|
filename?: string,
|
|
): UseVideoContextMenu {
|
|
const [position, setPosition] =
|
|
React.useState<MediaContextMenuPosition | null>(null);
|
|
const close = React.useCallback(() => setPosition(null), []);
|
|
useDismissMediaContextMenu(Boolean(position), close);
|
|
|
|
const onContextMenu = React.useCallback((event: React.MouseEvent) => {
|
|
event.preventDefault();
|
|
setPosition({ x: event.clientX, y: event.clientY });
|
|
}, []);
|
|
|
|
const items = React.useMemo<MediaContextMenuItem[]>(() => {
|
|
const entries: MediaContextMenuItem[] = [];
|
|
if (downloadUrl) {
|
|
entries.push({
|
|
label: "Download video",
|
|
onSelect: () => {
|
|
close();
|
|
invokeTauri("download_file", {
|
|
url: downloadUrl,
|
|
filename: resolveVideoDownloadFilename(filename),
|
|
}).catch((err: unknown) => {
|
|
toast.error(err instanceof Error ? err.message : "Download failed");
|
|
});
|
|
},
|
|
});
|
|
}
|
|
entries.push({
|
|
label: "Copy link",
|
|
onSelect: () => {
|
|
close();
|
|
copyTextToClipboard(downloadUrl ?? src, "Link copied to clipboard");
|
|
},
|
|
});
|
|
return entries;
|
|
}, [close, downloadUrl, filename, src]);
|
|
|
|
return {
|
|
onContextMenu,
|
|
menu: position ? (
|
|
<MediaContextMenu
|
|
dataAttributes={["data-video-context-menu"]}
|
|
items={items}
|
|
position={position}
|
|
/>
|
|
) : null,
|
|
};
|
|
}
|