mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02: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>