diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 4daadd365..15674a14e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -368,7 +368,15 @@ const overrides = new Map([ ["src/features/settings/ui/ProfileSettingsCard.tsx", 1033], // keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const // to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix. - ["src-tauri/src/app_state.rs", 1042], + // +10 (1042 -> 1052): media_fetch_client with redirect::Policy::none() so a + // relay 3xx cannot forward the minted auth header cross-origin (SSRF fix). + // +16 (1052 -> 1068): extracted that client into `build_media_fetch_client()` + // -> Result so the fail-closed invariant is testable (no silent redirect- + // following fallback; startup panics loudly instead). The function belongs + // here beside `build_app_state` and its sibling client; its doc comment + // carries the load-bearing SSRF rationale. Extraction would only relocate, + // not reduce, the security-critical code. + ["src-tauri/src/app_state.rs", 1068], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, @@ -396,7 +404,11 @@ const overrides = new Map([ // large shared renderers cannot grow further while follow-up splits land. // +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard). ["src/shared/ui/markdown.tsx", 2152], - ["src/shared/ui/VideoPlayer.tsx", 2199], + // +15 (2199 -> 2214): the video right-click Download/Copy menu's props, + // hook wiring, and render slot. The stateful menu logic (~52 lines) was + // extracted to useVideoContextMenu.tsx; what remains here is the component's + // public interface (downloadUrl/filename props) and cannot move out. + ["src/shared/ui/VideoPlayer.tsx", 2214], ["src/shared/ui/sidebar.tsx", 1042], // permission-outcome (fix #1381 regression): pendingPermissions state map, // describePermissionOutcome helper, jsonRpcId key helper (handles both diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 0246c0a61..6d509318e 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -18,6 +18,15 @@ use crate::managed_agents::ManagedAgentProcess; pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, + /// A no-redirect client for authenticated relay media fetches (download, + /// clipboard copy, snapshot, editor). Every caller pre-validates the URL + /// origin, but the app-wide `http_client` follows redirects by default, so + /// a relay `/media/` URL returning a 3xx to an off-origin or private host + /// would forward the minted media Authorization header across origins — + /// a redirect-hop SSRF. This client treats any 3xx as a non-success + /// response (surfaced as an error) so the auth token never leaves the + /// validated relay origin. + pub media_fetch_client: reqwest::Client, /// Workspace-provided relay URL override. Set by `apply_workspace` on app /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, @@ -137,6 +146,27 @@ fn identity_from_env() -> Option { } } +/// Build the no-redirect HTTP client used for authenticated relay media +/// fetches (download / copy). +/// +/// This client is a security boundary, not a convenience: it carries a minted +/// media `Authorization` header, so it MUST NOT follow redirects. A relay 3xx +/// to an off-origin or private host would otherwise forward that header across +/// origins (a redirect-hop SSRF). `redirect::Policy::none()` returns the 3xx +/// verbatim so the caller can reject it. +/// +/// Returned as a `Result` so the fail-closed invariant is testable — callers +/// must never substitute a redirect-following client on build failure. Shares +/// the localhost `resolve`/pool config with the app-wide `http_client`. +pub fn build_media_fetch_client() -> reqwest::Result { + reqwest::Client::builder() + .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .redirect(reqwest::redirect::Policy::none()) + .build() +} + pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. @@ -159,6 +189,11 @@ pub fn build_app_state() -> AppState { .pool_max_idle_per_host(1) .build() .unwrap_or_else(|_| reqwest::Client::new()), + media_fetch_client: build_media_fetch_client().expect( + "media_fetch_client must build with redirect::Policy::none(); a \ + redirect-following fallback would forward the minted media auth \ + header across origins (redirect-hop SSRF)", + ), relay_url_override: Mutex::new(None), managed_agent_restore_pending: AtomicBool::new(false), shutdown_started: AtomicBool::new(false), diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index dcc57819b..1ba691597 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -264,14 +264,35 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result Option { + status.is_redirection().then(|| { + format!( + "media fetch refused: relay returned a {status} redirect, which is \ + not followed for authenticated downloads (redirect-hop SSRF guard)" + ) + }) +} + /// Core streaming fetcher with a caller-supplied byte cap. async fn fetch_blob_bytes_with_cap( url: &str, state: &State<'_, AppState>, cap: u64, ) -> Result, String> { - // Fetch bytes via the app's HTTP client (goes through WARP tunnel). - let mut req = state.http_client.get(url).timeout(DOWNLOAD_TIMEOUT); + // Fetch bytes via the no-redirect media client (goes through WARP tunnel). + // A no-redirect client keeps the minted media auth token from being + // forwarded across origins by a relay-issued 3xx (redirect-hop SSRF); a + // 3xx is returned verbatim and rejected by the `is_success` check below. + let mut req = state.media_fetch_client.get(url).timeout(DOWNLOAD_TIMEOUT); // Every caller pre-validates `url` against the relay origin via // `validate_download_url`, satisfying the mint_media_get_auth safety @@ -283,6 +304,10 @@ async fn fetch_blob_bytes_with_cap( let resp = req.send().await.map_err(|e| classify_request_error(&e))?; + if let Some(err) = redirect_refusal_error(resp.status()) { + return Err(err); + } + if !resp.status().is_success() { return Err(relay_error_message(resp).await); } @@ -826,4 +851,143 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("/media/")); } + + // Video Download reuses `download_file`, which runs the same + // `validate_download_url` gate as image download. `validate_download_url` + // is extension-agnostic (it only checks scheme, origin, and the `/media/` + // path prefix), so a relay-hosted mp4/webm passes exactly like an image, + // and an off-relay or private-host video is rejected exactly like an + // off-relay image. These cases pin that parity so a future change can't + // silently narrow the video download path's SSRF protection. + #[test] + fn test_validate_download_url_valid_relay_video_mp4() { + assert!(validate_download_url( + "https://relay.example.com/media/abcdef1234567890.mp4", + RELAY_BASE, + ) + .is_ok()); + } + + #[test] + fn test_validate_download_url_valid_relay_video_webm() { + assert!( + validate_download_url("https://relay.example.com/media/abc123.webm", RELAY_BASE) + .is_ok() + ); + } + + #[test] + fn test_validate_download_url_non_relay_video_rejected() { + let result = validate_download_url("https://evil.example.com/media/clip.mp4", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); + } + + #[test] + fn test_validate_download_url_private_host_video_rejected() { + // Off-relay private host serving a video must be rejected before any + // fetch — same SSRF gate as image download. + let result = validate_download_url("http://127.0.0.1/media/clip.mp4", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); + } + + /// Redirect-hop SSRF guard: the media fetch client must NOT follow a 3xx, + /// and the command-facing error must identify the refused redirect. + /// + /// `validate_download_url` only vets the *initial* URL, so a relay that + /// returned a redirect to an off-origin or private host would, under a + /// redirect-following client, forward the minted media Authorization + /// header across origins. The client `build_media_fetch_client()` produces + /// (the same one `fetch_blob_bytes_with_cap` uses via `AppState`) is built + /// with `redirect::Policy::none()`, so the 302 comes back verbatim and + /// `redirect_refusal_error` — the same mapping the command applies — turns + /// it into an actionable redirect error, not a silent cross-origin fetch. + /// + /// A loopback `std::net::TcpListener` (no extra tokio feature) serves one + /// raw `302` pointing at an off-origin target and records how many + /// connections it accepts. + #[tokio::test] + async fn media_fetch_client_does_not_follow_redirects() { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let connections = Arc::new(AtomicUsize::new(0)); + + let server_connections = Arc::clone(&connections); + let server = std::thread::spawn(move || { + // Accept exactly one connection; if the client followed the + // redirect it would open a second one to the (unrelated) target, + // but that target is never this server, so a second accept here + // would only happen on an unexpected retry. We serve one 302 and + // return, so the count stays at 1 for a compliant no-redirect client. + if let Ok((mut stream, _)) = listener.accept() { + server_connections.fetch_add(1, Ordering::SeqCst); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = "HTTP/1.1 302 Found\r\n\ + Location: http://169.254.169.254/latest/meta-data/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + // Drive the exact client the command path uses, not an ad-hoc one. + let client = crate::app_state::build_media_fetch_client() + .expect("media fetch client must build with no-redirect policy"); + let resp = client + .get(format!("http://{addr}/media/clip.mp4")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .expect("request should complete without following the redirect"); + + // The 302 is returned verbatim — not the 169.254.x target's response. + assert_eq!(resp.status().as_u16(), 302); + assert!(!resp.status().is_success()); + + // The command maps that status through `redirect_refusal_error`; the + // user-facing error must name the redirect, not read as a generic + // relay failure. + let err = redirect_refusal_error(resp.status()) + .expect("a 3xx must map to a redirect-refusal error"); + assert!( + err.contains("redirect") && err.contains("302"), + "error must identify the refused 302 redirect, got: {err}", + ); + + server.join().unwrap(); + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "exactly one request must be issued — the redirect must not be followed", + ); + } + + #[test] + fn build_media_fetch_client_succeeds_with_no_redirect_policy() { + // The fail-closed invariant: construction must not silently degrade to + // a redirect-following client. If this ever starts failing, startup + // panics loudly (see `build_app_state`) rather than substituting an + // insecure client. + assert!( + crate::app_state::build_media_fetch_client().is_ok(), + "media fetch client must build; a redirect-following fallback is forbidden", + ); + } + + #[test] + fn redirect_refusal_error_only_fires_for_3xx() { + // 3xx → redirect-identifying error; success/non-3xx → None (fall + // through to the normal success or relay-error handling). + assert!(redirect_refusal_error(reqwest::StatusCode::FOUND).is_some()); + assert!(redirect_refusal_error(reqwest::StatusCode::TEMPORARY_REDIRECT).is_some()); + assert!(redirect_refusal_error(reqwest::StatusCode::OK).is_none()); + assert!(redirect_refusal_error(reqwest::StatusCode::NOT_FOUND).is_none()); + } } diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs index e436f9685..36a357eba 100644 --- a/desktop/src/shared/lib/mediaUrl.test.mjs +++ b/desktop/src/shared/lib/mediaUrl.test.mjs @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { mediaProxyUrl } from "./mediaUrl.ts"; +import { + beginRelayOriginFetch, + getCachedRelayOrigin, + mediaProxyUrl, + resetMediaCaches, + subscribeRelayOrigin, + withDeadline, +} from "./mediaUrl.ts"; const HASH = "a".repeat(64); @@ -11,3 +18,176 @@ test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", () `http://127.0.0.1:54321/media/${HASH}.png`, ); }); + +test("relay-origin store: a resolved origin publishes and notifies subscribers", () => { + resetMediaCaches(); + let notifications = 0; + const unsubscribe = subscribeRelayOrigin(() => notifications++); + + const publish = beginRelayOriginFetch(); + publish("https://relay.example"); + + assert.equal(getCachedRelayOrigin(), "https://relay.example"); + assert.equal(notifications, 1); + + unsubscribe(); + resetMediaCaches(); +}); + +test("relay-origin store: unsubscribe removes exactly its own listener", () => { + resetMediaCaches(); + let kept = 0; + let dropped = 0; + const unsubscribeKept = subscribeRelayOrigin(() => kept++); + const unsubscribeDropped = subscribeRelayOrigin(() => dropped++); + + // Dropping one listener must not affect the other. + unsubscribeDropped(); + beginRelayOriginFetch()("https://relay.example"); + assert.equal(kept, 1); + assert.equal(dropped, 0); + + unsubscribeKept(); + resetMediaCaches(); +}); + +test("relay-origin store: reset notifies only on an actual snapshot change", () => { + resetMediaCaches(); + let notifications = 0; + const unsubscribe = subscribeRelayOrigin(() => notifications++); + + // Origin already null → reset is a no-op for listeners. + resetMediaCaches(); + assert.equal(notifications, 0); + + // Now resolve, then reset: the reset clears a non-null origin, so it fires. + beginRelayOriginFetch()("https://relay.example"); + assert.equal(notifications, 1); + resetMediaCaches(); + assert.equal(getCachedRelayOrigin(), null); + assert.equal(notifications, 2); + + unsubscribe(); +}); + +test("relay-origin store: a late fetch from the previous community never regresses the snapshot", () => { + resetMediaCaches(); + const unsubscribe = subscribeRelayOrigin(() => {}); + + // Community A starts a fetch, then the user switches workspaces (reset), + // then community B starts its own fetch. + const publishA = beginRelayOriginFetch(); + resetMediaCaches(); + const publishB = beginRelayOriginFetch(); + + // A resolves late — its generation is stale, so it must be discarded. + publishA("https://relay-a.example"); + assert.equal(getCachedRelayOrigin(), null); + + // B resolves — it is current, so it wins. + publishB("https://relay-b.example"); + assert.equal(getCachedRelayOrigin(), "https://relay-b.example"); + + // A late duplicate from A after B must still not clobber B. + publishA("https://relay-a.example"); + assert.equal(getCachedRelayOrigin(), "https://relay-b.example"); + + unsubscribe(); + resetMediaCaches(); +}); + +test("relay-origin store: a failed attempt then a later success publishes exactly once", () => { + // Mirrors the `fetchProxyPort` retry loop: each poll attempt calls + // `beginRelayOriginFetch()` and only publishes if the invoke resolves. An + // early attempt whose invoke rejects (Tauri bridge not ready) never calls its + // publisher, so nothing is published; a later attempt succeeds and publishes. + resetMediaCaches(); + let notifications = 0; + const unsubscribe = subscribeRelayOrigin(() => notifications++); + + // Attempt 1: invoke rejects — publisher is never invoked. + beginRelayOriginFetch(); + assert.equal(getCachedRelayOrigin(), null); + assert.equal(notifications, 0); + + // Attempt 2: invoke resolves — publishes once, notifies once. + beginRelayOriginFetch()("https://relay.example"); + assert.equal(getCachedRelayOrigin(), "https://relay.example"); + assert.equal(notifications, 1); + + unsubscribe(); + resetMediaCaches(); +}); + +test("relay-origin store: a reset between a failed attempt and its late success discards the stale result", () => { + // A workspace switch (reset) during the retry sequence must invalidate a + // still-in-flight attempt from the previous community, even if that attempt + // eventually resolves after the switch. + resetMediaCaches(); + const unsubscribe = subscribeRelayOrigin(() => {}); + + // Attempt from community A begins, then the user switches (reset), then a + // fresh attempt from community B begins and succeeds. + const publishA = beginRelayOriginFetch(); + resetMediaCaches(); + beginRelayOriginFetch()("https://relay-b.example"); + assert.equal(getCachedRelayOrigin(), "https://relay-b.example"); + + // A's invoke finally resolves late — stale generation, so it is dropped. + publishA("https://relay-a.example"); + assert.equal(getCachedRelayOrigin(), "https://relay-b.example"); + + unsubscribe(); + resetMediaCaches(); +}); + +test("withDeadline: a never-settling invoke resolves to null at the deadline", async () => { + // The poll loop bounds each invoke by the remaining budget so a wedged IPC + // bridge (a promise that never settles) can't hang startup past the timeout. + const neverSettles = new Promise(() => {}); + const result = await withDeadline(neverSettles, Date.now() + 20); + assert.equal(result, null); +}); + +test("withDeadline: a value that settles before the deadline is returned", async () => { + const result = await withDeadline( + Promise.resolve("http://relay.example"), + Date.now() + 1000, + ); + assert.equal(result, "http://relay.example"); +}); + +test("withDeadline: an already-passed deadline resolves to null without awaiting", async () => { + const result = await withDeadline(new Promise(() => {}), Date.now() - 1); + assert.equal(result, null); +}); + +test("withDeadline: a rejection before the deadline propagates to the caller", async () => { + await assert.rejects( + withDeadline(Promise.reject(new Error("ipc failed")), Date.now() + 1000), + /ipc failed/, + ); +}); + +test("withDeadline: a rejection after the deadline is observed, not unhandled", async () => { + // The timeout wins first (deadline ~10ms), then the invoke rejects ~30ms + // later. That late rejection must be swallowed by the internal no-op catch, + // not surface as an unhandled rejection. Register a listener to prove it. + let unhandled; + const onUnhandled = (reason) => { + unhandled = reason; + }; + process.on("unhandledRejection", onUnhandled); + try { + const slowReject = new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("late ipc failure")), 30), + ); + const result = await withDeadline(slowReject, Date.now() + 10); + assert.equal(result, null); + // Give the late rejection time to fire and be (not) reported. + await new Promise((r) => setTimeout(r, 40)); + assert.equal(unhandled, undefined); + } finally { + process.off("unhandledRejection", onUnhandled); + } +}); diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index 94e4cc41e..6503d75dd 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -20,7 +20,7 @@ import { invoke } from "@tauri-apps/api/core"; // Matches: https://anything.com/media/{64-hex}.{ext} // Also matches thumbnails: /media/{64-hex}.thumb.jpg const RELAY_MEDIA_RE = - /^(?:https?:\/\/[^/]+)\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp|mp4)(?:\?.*)?)$/; + /^(?:https?:\/\/[^/]+)\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp|mp4|webm|mov)(?:\?.*)?)$/; /** Cached proxy port — fetched once from the Tauri backend. */ let cachedPort: number | null = null; @@ -29,38 +29,150 @@ let portPromise: Promise | null = null; /** Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"). */ let cachedRelayOrigin: string | null = null; +/** + * Generation token for the relay-origin fetch. Bumped on every + * `resetMediaCaches` (i.e. workspace switch) so an origin fetch started for + * the previous community can never publish its stale origin after the switch: + * only a resolution whose generation still matches the current one is applied. + */ +let relayOriginGeneration = 0; + +/** `useSyncExternalStore` listeners for relay-origin changes. */ +const relayOriginListeners = new Set<() => void>(); + +function notifyRelayOriginListeners(): void { + for (const listener of relayOriginListeners) listener(); +} + +/** + * Publish a resolved relay origin, but only if `generation` is still current + * (the fetch wasn't superseded by a workspace switch). Notifies subscribers + * only on an actual snapshot change so `useSyncExternalStore` doesn't churn. + */ +function setRelayOrigin(origin: string | null, generation: number): void { + if (generation !== relayOriginGeneration) return; + if (cachedRelayOrigin === origin) return; + cachedRelayOrigin = origin; + notifyRelayOriginListeners(); +} + +/** + * Begin a relay-origin fetch: captures the current generation and returns a + * publisher bound to it. The publisher applies the resolved origin only if no + * workspace switch (`resetMediaCaches`) has happened in the meantime, so a + * fetch started for community A can never publish A's origin after a switch to + * community B. Callers invoke the returned function once the origin resolves. + */ +export function beginRelayOriginFetch(): (origin: string | null) => void { + const generation = relayOriginGeneration; + return (origin) => setRelayOrigin(origin, generation); +} + +/** + * Subscribe to relay-origin changes. Returns a stable unsubscribe function + * (the same closure identity for the life of the subscription), as + * `useSyncExternalStore` requires. + */ +export function subscribeRelayOrigin(listener: () => void): () => void { + relayOriginListeners.add(listener); + return () => { + relayOriginListeners.delete(listener); + }; +} + const POLL_INTERVAL_MS = 100; const POLL_TIMEOUT_MS = 5000; +/** + * Race `promise` against the remaining time until `deadline`, resolving to + * `null` if the deadline passes first. Bounds each poll invoke so a Tauri IPC + * call that never settles (bridge wedged, not merely unavailable) cannot hang + * the poll loop past its budget. The underlying invoke isn't cancellable, but + * abandoning its result is safe here: a late origin resolution is + * generation-guarded, and a late port is simply ignored once the loop returns. + * + * Exported for unit tests (the never-settling case) — it is a self-contained, + * generally-useful timeout primitive, not a seam into `fetchProxyPort`. + */ +export function withDeadline( + promise: Promise, + deadline: number, +): Promise { + const remaining = deadline - Date.now(); + // A late rejection (the invoke rejects after the timeout already won the + // race) would otherwise surface as an unhandled rejection, so attach a + // no-op catch that keeps it observed regardless of which side wins. + promise.catch(() => {}); + if (remaining <= 0) return Promise.resolve(null); + return new Promise((resolve, reject) => { + // Clear the timer once the promise settles so a settled race doesn't leave + // a dangling timeout for the rest of the poll budget. + const timer = setTimeout(() => resolve(null), remaining); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + /** * Poll `get_media_proxy_port` until we get a non-zero port or timeout. - * Also fetches the relay HTTP base URL for origin-checking. + * Also resolves the relay HTTP base URL for origin-checking. * Returns the port, or null if the proxy never came up. */ async function fetchProxyPort(): Promise { - // Fetch relay origin in parallel — fire-and-forget, no retry needed. - if (!cachedRelayOrigin) { - invoke("get_relay_http_url") - .then((url) => { - cachedRelayOrigin = url.replace(/\/+$/, ""); - }) - .catch(() => {}); - } - + // Resolve the relay origin alongside the port, retried inside the same poll + // loop. Both invokes can reject early (e.g. Tauri IPC not ready at module + // load); the port already retries, and the origin must too — a single + // fire-and-forget attempt that fails before the bridge is up would leave the + // origin unresolved forever, hiding relay Download eligibility. The publisher + // captures the generation at each attempt so a resolution that lands after a + // workspace switch is discarded rather than publishing a stale origin. const deadline = Date.now() + POLL_TIMEOUT_MS; while (Date.now() < deadline) { - try { - const port = await invoke("get_media_proxy_port"); - if (port > 0) { - cachedPort = port; - return port; + if (!cachedRelayOrigin) { + const publishRelayOrigin = beginRelayOriginFetch(); + try { + const url = await withDeadline( + invoke("get_relay_http_url"), + deadline, + ); + if (url !== null) publishRelayOrigin(url.replace(/\/+$/, "")); + } catch { + // invoke failed (e.g. Tauri IPC not ready yet) — keep retrying } - } catch { - // invoke failed (e.g. Tauri IPC not ready yet) — keep retrying } + + if (!cachedPort) { + try { + const port = await withDeadline( + invoke("get_media_proxy_port"), + deadline, + ); + if (port !== null && port > 0) cachedPort = port; + } catch { + // invoke failed (e.g. Tauri IPC not ready yet) — keep retrying + } + } + + // Both readiness results complete independently. Keep polling until BOTH + // land: a resolved port lets URL rewriting proceed, but relay-origin + // resolution gates Download eligibility, so we must not stop retrying the + // origin just because the port is ready (nothing else re-enters this loop + // once the port is cached). Each invoke is bounded by the remaining + // deadline (`withDeadline`) so a never-settling IPC call can't hang the + // loop; every late origin result is generation-guarded. + if (cachedPort && cachedRelayOrigin) return cachedPort; + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } - return null; + return cachedPort; } /** Eagerly fetch the port at module load so it's ready by first render. */ @@ -73,11 +185,31 @@ if (typeof window !== "undefined") { /** * Reset module-level caches so the next render re-fetches the proxy port * and relay origin for the new community. + * + * Bumps the origin generation so any in-flight fetch from the previous + * community is discarded on resolution, and notifies subscribers only if the + * origin actually changes (a reset from an already-null origin is a no-op for + * listeners). */ export function resetMediaCaches(): void { cachedPort = null; portPromise = null; - cachedRelayOrigin = null; + relayOriginGeneration += 1; + if (cachedRelayOrigin !== null) { + cachedRelayOrigin = null; + notifyRelayOriginListeners(); + } +} + +/** + * The relay origin (e.g. `https://buzz-oss.stage.blox.sqprod.co`) if it has + * been resolved, else `null`. Synchronous best-effort read of the same cache + * `rewriteRelayUrl` uses. Callers that need a hard SSRF guarantee must still + * rely on the Rust `validate_download_url` gate; this only drives UX (e.g. + * whether to offer a Download action that could otherwise only error). + */ +export function getCachedRelayOrigin(): string | null { + return cachedRelayOrigin; } /** diff --git a/desktop/src/shared/lib/useRelayOrigin.ts b/desktop/src/shared/lib/useRelayOrigin.ts new file mode 100644 index 000000000..c29c20d32 --- /dev/null +++ b/desktop/src/shared/lib/useRelayOrigin.ts @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { getCachedRelayOrigin, subscribeRelayOrigin } from "./mediaUrl"; + +/** + * The resolved relay origin, re-rendering when it resolves or changes. + * + * The origin is fetched asynchronously (see `mediaUrl.ts`) and is commonly + * still `null` on a component's first render. Reading it through this store + * subscription — rather than a bare synchronous `getCachedRelayOrigin()` — + * means download eligibility recomputes the moment the origin resolves and + * again on a workspace switch, instead of being frozen at first-render time. + * + * The server snapshot is `null`: there is no relay origin during SSR/prerender + * (no Tauri backend), and callers already treat an unresolved origin as + * "not yet downloadable" (fail closed). + */ +export function useRelayOrigin(): string | null { + return React.useSyncExternalStore( + subscribeRelayOrigin, + getCachedRelayOrigin, + () => null, + ); +} diff --git a/desktop/src/shared/ui/VideoPlayer.tsx b/desktop/src/shared/ui/VideoPlayer.tsx index 4de5f44ac..908bd8e3f 100644 --- a/desktop/src/shared/ui/VideoPlayer.tsx +++ b/desktop/src/shared/ui/VideoPlayer.tsx @@ -13,7 +13,6 @@ import { VolumeX, X, } from "lucide-react"; - import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -28,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { Spinner } from "./spinner"; import { useNaturalVideoAspectRatio } from "./videoAspectRatio"; +import { useVideoContextMenu } from "./useVideoContextMenu"; import { getInlinePlaybackPosition, getReviewPlaybackPosition, @@ -92,6 +92,14 @@ type VideoPlayerProps = { durationSeconds?: number; reviewKey?: string; reviewContext?: VideoReviewContext; + /** + * Original relay `/media/` URL for the right-click Download action, distinct + * from the possibly-proxied `src` the download command's SSRF check rejects. + * Omitted for non-relay sources, which hide the Download item. + */ + downloadUrl?: string; + /** imeta `filename`, used as the save-dialog name. */ + filename?: string; }; type TimecodedComment = { @@ -703,6 +711,8 @@ export function VideoPlayer({ durationSeconds, reviewKey, reviewContext, + downloadUrl, + filename, }: VideoPlayerProps) { const persistedReviewKey = reviewKey ?? src; const videoRef = React.useRef(null); @@ -740,6 +750,9 @@ export function VideoPlayer({ number | null >(null); + const { onContextMenu: onSurfaceContextMenu, menu: videoContextMenu } = + useVideoContextMenu(src, downloadUrl, filename); + React.useEffect(() => { // The imeta duration seeds the timeline before metadata loads; metadata // (set via onLoadedMetadata below) stays authoritative once known. @@ -988,6 +1001,7 @@ export function VideoPlayer({ ref={inlineSurfaceRef} className="group/video relative isolate max-w-full overflow-hidden rounded-2xl border border-border/70 bg-black" style={inlineSurfaceStyle} + onContextMenuCapture={onSurfaceContextMenu} > {/* Cover, not contain: when the surface's max-height clamp breaks the aspect match (tall videos), fill the tile and crop instead @@ -1175,6 +1189,7 @@ export function VideoPlayer({ ) : null} + {videoContextMenu} void) { - React.useEffect(() => { - if (!isOpen) return; - // 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", onDismiss); - window.addEventListener("contextmenu", onDismiss); - window.addEventListener("scroll", onDismiss, true); - }, 0); - return () => { - window.clearTimeout(timer); - if (attached) { - window.removeEventListener("click", onDismiss); - window.removeEventListener("contextmenu", onDismiss); - window.removeEventListener("scroll", onDismiss, true); - } - }; - }, [isOpen, onDismiss]); -} - -function ImageContextMenu({ - onCopy, - onDownload, - portalContainer, - position, -}: { - onCopy: () => void; - onDownload: () => void; - portalContainer?: Element; - position: ImageContextMenuPosition; -}) { - const itemClass = - "flex min-h-9 w-full cursor-default select-none items-center rounded-lg py-2 pl-2 pr-4 text-sm outline-hidden hover:bg-muted/50 hover:text-foreground"; - return createPortal( -
- - -
, - portalContainer ?? document.body, - ); -} - function ImageZoomOverlay({ alt, galleryIndex = 0, @@ -287,7 +222,7 @@ function ImageZoomOverlay({ const [hasEntered, setHasEntered] = React.useState(prefersReducedMotion); const [isAdjustingZoom, setIsAdjustingZoom] = React.useState(false); const [isGalleryNavigating, setIsGalleryNavigating] = React.useState(false); - const [menu, setMenu] = React.useState(null); + const [menu, setMenu] = React.useState(null); const currentItem = items[currentIndex] ?? items[0]; const basisBox = React.useMemo( () => imageLightboxBasisBoxForItem(currentItem, sourceBox), @@ -446,7 +381,7 @@ function ImageZoomOverlay({ navigateGallery(currentIndex + 1); }, [currentIndex, navigateGallery]); - useDismissImageContextMenu(Boolean(menu), closeMenu); + useDismissMediaContextMenu(Boolean(menu), closeMenu); React.useEffect(() => { if (prefersReducedMotion) { @@ -1042,9 +977,15 @@ function ImageZoomOverlay({ {menu && canActOnCurrentImage ? ( - @@ -1072,7 +1013,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { sourceScope: Element | null; } | null>(null); const [isHiddenInSpoiler, setIsHiddenInSpoiler] = React.useState(false); - const [menu, setMenu] = React.useState(null); + const [menu, setMenu] = React.useState(null); const inlineImageRef = React.useRef(null); const thumbnailImageRef = React.useRef(null); const triggerRef = React.useRef(null); @@ -1158,7 +1099,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { }, []); const closeMenu = React.useCallback(() => setMenu(null), []); - useDismissImageContextMenu(Boolean(menu), closeMenu); + useDismissMediaContextMenu(Boolean(menu), closeMenu); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -1279,9 +1220,12 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { /> {menu && src ? ( - handleCopyImage(src)} - onDownload={() => handleDownload(src)} + handleCopyImage(src) }, + { label: "Download image", onSelect: () => handleDownload(src) }, + ]} position={menu} /> ) : null} @@ -1328,6 +1272,85 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) { ); } +/** + * An external `[text](href)` link with a custom right-click menu. + * + * Buzz renders inside a native webview whose default context menu has no + * useful link actions, so a plain right-click on a link is a no-op. This adds + * an in-app menu with "Open link" (via the OS opener, matching the anchor's + * left-click `target="_blank"` behavior) and "Copy link" (the real href, not + * the masked display text). + */ +function ExternalLinkAnchor({ + anchorProps, + children, + href, + isLinearLink, + label, +}: { + anchorProps: React.ComponentPropsWithoutRef<"a">; + children: React.ReactNode; + href: string | undefined; + isLinearLink: boolean; + label: string; +}) { + const [menu, setMenu] = React.useState(null); + const closeMenu = React.useCallback(() => setMenu(null), []); + useDismissMediaContextMenu(Boolean(menu), closeMenu); + + const anchor = ( + { + if (!href) return; + event.preventDefault(); + setMenu({ x: event.clientX, y: event.clientY }); + }} + rel="noreferrer" + target="_blank" + > + {children} + + ); + + return ( + <> + + {anchor} + + {menu && href ? ( + { + closeMenu(); + void openUrl(href).catch(() => { + toast.error("Failed to open link"); + }); + }, + }, + { + label: "Copy link", + onSelect: () => { + closeMenu(); + copyTextToClipboard(href, "Link copied to clipboard"); + }, + }, + ]} + position={menu} + /> + ) : null} + + ); +} + function createMarkdownComponents( interactive = true, mediaInset = false, @@ -1445,25 +1468,15 @@ function createMarkdownComponents( const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null; const isLinearLink = supportedLinkPreview?.kind === "linear-issue"; - const anchor = ( - {children} - - ); - - return ( - - {anchor} - + ); } @@ -1556,16 +1569,15 @@ function createMarkdownComponents( hr: () =>
, img: function MarkdownImage({ alt, src }) { const { imetaByUrl } = useMarkdownRuntime(); - const resolvedSrc = src ? rewriteRelayUrl(src) : src; + const entry = src ? imetaByUrl?.get(src) : undefined; + const isVideo = src ? isVideoMedia(src, entry?.m) : false; if (!interactive) { - const fallbackLabel = resolvedSrc?.endsWith(".mp4") - ? "Video attachment" - : "Image attachment"; + const fallbackLabel = isVideo ? "Video attachment" : "Image attachment"; return {alt?.trim() || fallbackLabel}; } - if (resolvedSrc?.endsWith(".mp4")) { - const entry = src ? imetaByUrl?.get(src) : undefined; + const resolvedSrc = src ? rewriteRelayUrl(src) : src; + if (isVideo && src && resolvedSrc) { return ( ); } - const entry = src ? imetaByUrl?.get(src) : undefined; return (