Integrate bugbash/dawn-media: right-click Download/Copy menus for images, links, and videos

Adds a shared MediaContextMenu with relay-eligibility gating (fail-closed
off-relay), fixes RELAY_MEDIA_RE to recognize .webm/.mov, makes the
relay-origin fetch retried + generation-guarded with a hard 5s deadline,
and hardens the download HTTP client with redirect::Policy::none().

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/bugbash/dawn-media:
  Add link and video right-click Download/Copy menus (bug-bash media lane)
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-17 07:54:47 -04:00
co-authored by Tyler Longwell
17 changed files with 1299 additions and 132 deletions
+14 -2
View File
@@ -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
+35
View File
@@ -18,6 +18,15 @@ use crate::managed_agents::ManagedAgentProcess;
pub struct AppState {
pub keys: Mutex<Keys>,
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<Option<String>>,
@@ -137,6 +146,27 @@ fn identity_from_env() -> Option<Keys> {
}
}
/// 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> {
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),
@@ -264,14 +264,35 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result<Vec<
fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES).await
}
/// The command-facing error for a media-fetch response status, or `None` if
/// the status is success and the body should be read.
///
/// A no-redirect client surfaces a relay 3xx as a redirection status rather
/// than following it; that is reported explicitly as a redirect (not a bare
/// "relay returned 302") so the failure is actionable and cannot be mistaken
/// for an ordinary relay error — following it would forward the minted media
/// auth header across origins. Pulled out of `fetch_blob_bytes_with_cap` so
/// the redirect-refusal message is unit-testable without a Tauri `State`.
fn redirect_refusal_error(status: reqwest::StatusCode) -> Option<String> {
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<Vec<u8>, 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());
}
}
+181 -1
View File
@@ -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);
}
});
+152 -20
View File
@@ -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<number | null> | 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<T>(
promise: Promise<T>,
deadline: number,
): Promise<T | null> {
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<T | null>((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<number | null> {
// Fetch relay origin in parallel — fire-and-forget, no retry needed.
if (!cachedRelayOrigin) {
invoke<string>("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<number>("get_media_proxy_port");
if (port > 0) {
cachedPort = port;
return port;
if (!cachedRelayOrigin) {
const publishRelayOrigin = beginRelayOriginFetch();
try {
const url = await withDeadline(
invoke<string>("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<number>("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;
}
/**
+24
View File
@@ -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,
);
}
+16 -1
View File
@@ -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<HTMLVideoElement>(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({
</button>
) : null}
</div>
{videoContextMenu}
<VideoReviewDialog
comments={timecodedComments}
currentTime={reviewCurrentTime}
+117 -106
View File
@@ -9,6 +9,7 @@ import {
ZoomOut,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -22,6 +23,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
extractSupportedLinkPreviews,
parseSupportedLinkPreview,
@@ -43,11 +45,6 @@ import {
MENTION_CHIP_PREFIX_CLASS,
MESSAGE_MARKDOWN_CLASS,
} from "@/shared/ui/mentionChip";
import {
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
POPOVER_SHADOW_STYLE,
POPOVER_SURFACE_CLASS,
} from "@/shared/ui/popoverSurface";
import {
classifyChildren,
@@ -65,6 +62,12 @@ import {
import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { MarkdownInput } from "./markdown/MarkdownInput";
import {
MediaContextMenu,
type MediaContextMenuPosition,
useDismissMediaContextMenu,
} from "./markdown/MediaContextMenu";
import { isVideoMedia } from "./markdown/mediaEntry";
import {
clampImageLightboxZoom,
type ImageGalleryDirection,
@@ -142,11 +145,6 @@ type WebKitGestureLikeEvent = Event & {
scale?: number;
};
type ImageContextMenuPosition = {
x: number;
y: number;
};
function getImageLightboxFocusableElements(
container: HTMLElement,
): HTMLElement[] {
@@ -169,69 +167,6 @@ function getImageLightboxFocusableElements(
);
}
function useDismissImageContextMenu(isOpen: boolean, onDismiss: () => 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(
<div
className={cn(
"fixed z-[100] min-w-60 origin-top-left rounded-xl p-1 slide-in-from-top-1",
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
POPOVER_SURFACE_CLASS,
)}
data-image-context-menu=""
data-image-lightbox-controls=""
style={{ ...POPOVER_SHADOW_STYLE, left: position.x, top: position.y }}
>
<button type="button" className={itemClass} onClick={onCopy}>
Copy image
</button>
<button type="button" className={itemClass} onClick={onDownload}>
Download image
</button>
</div>,
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<ImageContextMenuPosition | null>(null);
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(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({
</div>
</div>
{menu && canActOnCurrentImage ? (
<ImageContextMenu
onCopy={handleMenuCopy}
onDownload={handleMenuDownload}
<MediaContextMenu
dataAttributes={[
"data-image-context-menu",
"data-image-lightbox-controls",
]}
items={[
{ label: "Copy image", onSelect: handleMenuCopy },
{ label: "Download image", onSelect: handleMenuDownload },
]}
portalContainer={dialogRef.current ?? undefined}
position={menu}
/>
@@ -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<ImageContextMenuPosition | null>(null);
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
const inlineImageRef = React.useRef<HTMLImageElement | null>(null);
const thumbnailImageRef = React.useRef<HTMLImageElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(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) {
/>
</button>
{menu && src ? (
<ImageContextMenu
onCopy={() => handleCopyImage(src)}
onDownload={() => handleDownload(src)}
<MediaContextMenu
dataAttributes={["data-image-context-menu"]}
items={[
{ label: "Copy image", onSelect: () => 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<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);
const anchor = (
<a
{...anchorProps}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
onContextMenuCapture={(event) => {
if (!href) return;
event.preventDefault();
setMenu({ x: event.clientX, y: event.clientY });
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
return (
<>
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
{menu && href ? (
<MediaContextMenu
dataAttributes={["data-link-context-menu"]}
items={[
{
label: "Open link",
onSelect: () => {
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 = (
<a
{...props}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
return (
<ExternalLinkAnchor
anchorProps={props}
href={href}
rel="noreferrer"
target="_blank"
isLinearLink={isLinearLink}
label={label}
>
{children}
</a>
);
return (
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
</ExternalLinkAnchor>
);
}
@@ -1556,16 +1569,15 @@ function createMarkdownComponents(
hr: () => <hr className="border-border/80" />,
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 <span>{alt?.trim() || fallbackLabel}</span>;
}
if (resolvedSrc?.endsWith(".mp4")) {
const entry = src ? imetaByUrl?.get(src) : undefined;
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
if (isVideo && src && resolvedSrc) {
return (
<span
className={cn(
@@ -1583,7 +1595,6 @@ function createMarkdownComponents(
</span>
);
}
const entry = src ? imetaByUrl?.get(src) : undefined;
return (
<span data-block-media="" className="block min-w-0 max-w-full">
<ImageBlock
@@ -1,8 +1,10 @@
import * as React from "react";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { VideoPlayer, type VideoReviewContext } from "../VideoPlayer";
import { isRelayDownloadable } from "./mediaEntry";
import type { ImetaEntry } from "./types";
import { aspectRatioFromDim } from "./utils";
@@ -30,6 +32,17 @@ export function MarkdownVideoPlayer({
src?: string;
}) {
const videoReviewContext = React.useContext(VideoReviewMarkdownContext);
// Download eligibility is independent of rendering as a video: only a
// relay-hosted `/media/` URL can pass the native `download_file` SSRF gate,
// so an external video renders and offers Copy Link but omits Download.
//
// Read the relay origin reactively: it resolves asynchronously and is
// commonly still null on first render, so a synchronous read would freeze
// eligibility (Download stays hidden even for a genuine relay URL). The
// subscription re-renders when the origin resolves and on workspace switch.
const relayOrigin = useRelayOrigin();
const downloadUrl =
src && isRelayDownloadable(src, relayOrigin ?? undefined) ? src : undefined;
// Look up poster frame from imeta tags (NIP-71 `image` field).
// Fall back to `thumb` for compatibility with older events.
const posterUrl = entry?.image ?? entry?.thumb;
@@ -54,6 +67,8 @@ export function MarkdownVideoPlayer({
durationSeconds={entry?.duration}
reviewKey={src ?? resolvedSrc}
reviewContext={resolvedReviewContext}
downloadUrl={downloadUrl}
filename={entry?.filename}
/>
);
}
@@ -0,0 +1,111 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/shared/lib/cn";
import {
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
POPOVER_SHADOW_STYLE,
POPOVER_SURFACE_CLASS,
} from "@/shared/ui/popoverSurface";
export type MediaContextMenuPosition = {
x: number;
y: number;
};
export type MediaContextMenuItem = {
label: string;
onSelect: () => void;
};
/**
* Dismiss listeners for a custom right-click menu.
*
* Deferring attachment until after the current event-loop turn matters: the
* right-click that opens the menu (a `contextmenu` on mousedown) is often
* followed by a trailing `click`/`pointerup` on the same interaction — and
* some webviews emit a platform `click` on right-button release. Attaching
* synchronously lets that trailing event immediately dismiss the menu, so it
* only flashes. Deferring guarantees the opening interaction can never be the
* one that closes it.
*/
export function useDismissMediaContextMenu(
isOpen: boolean,
onDismiss: () => void,
) {
React.useEffect(() => {
if (!isOpen) return;
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]);
}
/**
* A small right-click menu for media and links, positioned at the pointer.
*
* Rendered into `portalContainer` (defaults to `document.body`) so it escapes
* overflow-clipped ancestors. Callers own open/close state and must pair this
* with `useDismissMediaContextMenu`.
*/
export function MediaContextMenu({
dataAttributes,
items,
portalContainer,
position,
}: {
/**
* Optional empty-valued data attributes (e.g. `["data-image-context-menu"]`)
* set on the menu root so e2e locators can target a specific menu variant —
* image, link, and video menus pass distinct attributes so tests never alias
* one surface for another. The image variant also passes
* `data-image-lightbox-controls` so the in-lightbox dismiss guard treats its
* own menu as an interior control. All menus additionally carry the shared
* generic `data-media-context-menu` marker.
*/
dataAttributes?: string[];
items: MediaContextMenuItem[];
portalContainer?: Element;
position: MediaContextMenuPosition;
}) {
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(
<div
className={cn(
"fixed z-[100] min-w-60 origin-top-left rounded-xl p-1 slide-in-from-top-1",
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
POPOVER_SURFACE_CLASS,
)}
data-media-context-menu=""
{...Object.fromEntries(
(dataAttributes ?? []).map((attribute) => [attribute, ""]),
)}
style={{ ...POPOVER_SHADOW_STYLE, left: position.x, top: position.y }}
>
{items.map((item) => (
<button
className={itemClass}
key={item.label}
onClick={item.onSelect}
type="button"
>
{item.label}
</button>
))}
</div>,
portalContainer ?? document.body,
);
}
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { isRelayDownloadable, isVideoMedia } from "./mediaEntry.ts";
const RELAY = "https://relay.example.com";
const relayUrl = (name) => `${RELAY}/media/${name}`;
// ── isVideoMedia: MIME-first classification ──────────────────────────────
test("isVideoMedia: video/* MIME classifies as video regardless of extension", () => {
assert.equal(isVideoMedia(relayUrl("abc"), "video/mp4"), true);
assert.equal(isVideoMedia(relayUrl("abc.jpg"), "video/webm"), true);
});
test("isVideoMedia: MIME wins for an extensionless relay URL", () => {
// The relay path is a content hash with no extension; MIME is the only signal.
assert.equal(isVideoMedia(relayUrl("deadbeef"), "video/quicktime"), true);
});
test("isVideoMedia: uppercase MIME still matches", () => {
assert.equal(isVideoMedia(relayUrl("abc"), "VIDEO/MP4"), true);
});
test("isVideoMedia: image MIME is not a video", () => {
assert.equal(isVideoMedia(relayUrl("abc.mp4"), "image/png"), false);
});
// ── isVideoMedia: legacy extension fallback (no MIME) ─────────────────────
test("isVideoMedia: legacy mp4/webm/mov extensions classify as video", () => {
assert.equal(isVideoMedia(relayUrl("abc.mp4")), true);
assert.equal(isVideoMedia(relayUrl("abc.webm")), true);
assert.equal(isVideoMedia(relayUrl("abc.mov")), true);
});
test("isVideoMedia: uppercase extension classifies as video", () => {
assert.equal(isVideoMedia(relayUrl("abc.MP4")), true);
assert.equal(isVideoMedia(relayUrl("abc.WebM")), true);
});
test("isVideoMedia: extension with a query string still classifies", () => {
assert.equal(isVideoMedia(relayUrl("abc.mp4?v=2")), true);
assert.equal(isVideoMedia(relayUrl("abc.webm#t=10")), true);
});
test("isVideoMedia: image extensions are not videos", () => {
assert.equal(isVideoMedia(relayUrl("abc.jpg")), false);
assert.equal(isVideoMedia(relayUrl("abc.png")), false);
});
test("isVideoMedia: an extension substring is not enough", () => {
// "notmp4" must not match "mp4" — only the real trailing extension counts.
assert.equal(isVideoMedia(relayUrl("clip.notmp4")), false);
});
test("isVideoMedia: malformed / extensionless URL without MIME is not a video", () => {
assert.equal(isVideoMedia("not a url"), false);
assert.equal(isVideoMedia(relayUrl("deadbeef")), false);
});
// ── isRelayDownloadable: eligibility, independent of render kind ──────────
test("isRelayDownloadable: relay /media/ URL on the resolved origin is eligible", () => {
assert.equal(isRelayDownloadable(relayUrl("abc.mp4"), RELAY), true);
});
test("isRelayDownloadable: off-relay URL is not eligible", () => {
assert.equal(
isRelayDownloadable("https://evil.example.com/media/abc.mp4", RELAY),
false,
);
});
test("isRelayDownloadable: non-/media/ path on the relay is not eligible", () => {
assert.equal(isRelayDownloadable(`${RELAY}/other/abc.mp4`, RELAY), false);
});
test("isRelayDownloadable: malformed URL is not eligible", () => {
assert.equal(isRelayDownloadable("not a url", RELAY), false);
});
test("isRelayDownloadable: unresolved origin fails closed (no Download offered)", () => {
// Before the relay origin resolves we can't distinguish a relay /media/ URL
// from an off-relay one, so eligibility fails closed — offering Download for
// an off-relay URL would only error, and the Rust gate rejects it anyway.
// Callers read the origin from a reactive source so eligibility recomputes
// once it resolves (see the transition cases below).
assert.equal(isRelayDownloadable(relayUrl("abc.mp4")), false);
assert.equal(isRelayDownloadable(relayUrl("abc.mp4"), undefined), false);
assert.equal(
isRelayDownloadable("https://evil.example.com/media/abc.mp4"),
false,
);
});
test("isRelayDownloadable: unresolved → relay origin makes a relay URL eligible", () => {
const src = relayUrl("abc.mp4");
// First render: origin unresolved → Download hidden.
assert.equal(isRelayDownloadable(src, undefined), false);
// Origin resolves to the relay → the same URL becomes eligible.
assert.equal(isRelayDownloadable(src, RELAY), true);
});
test("isRelayDownloadable: unresolved → off-relay origin keeps an external URL hidden", () => {
const src = "https://evil.example.com/media/abc.mp4";
// First render: origin unresolved → hidden.
assert.equal(isRelayDownloadable(src, undefined), false);
// Origin resolves to a different (relay) origin → still hidden.
assert.equal(isRelayDownloadable(src, RELAY), false);
});
@@ -0,0 +1,77 @@
/**
* Pure classification of a markdown media URL: whether it should render as a
* video, and whether it can be downloaded — two related but independent
* questions.
*
* `isVideoMedia` decides the render path (video player vs. image block).
* `isRelayDownloadable` decides download eligibility, which is separate: an
* off-relay video still renders as a video and can be Copy-Link'd, it just
* can't be downloaded (the Rust `validate_download_url` SSRF gate accepts only
* relay `/media/` origins, so offering Download for an external URL would only
* produce an error).
*
* Kept DOM-free so the branch logic is unit-testable without a webview.
*/
/** Legacy video extensions, used only when an imeta MIME type is absent. */
const VIDEO_EXTENSIONS = ["mp4", "webm", "mov"] as const;
/** The lowercased path extension of a URL, ignoring query strings and hashes. */
function urlPathExtension(src: string): string | undefined {
let pathname: string;
try {
pathname = new URL(src).pathname;
} catch {
// Relative or malformed URL — strip query/hash by hand.
pathname = src.split(/[?#]/, 1)[0];
}
const lastDot = pathname.lastIndexOf(".");
if (lastDot < 0 || lastDot === pathname.length - 1) return undefined;
return pathname.slice(lastDot + 1).toLowerCase();
}
/**
* Whether `src` should render as a video.
*
* The imeta MIME type is authoritative when present (uploads tag every
* attachment with `m`): a `video/*` MIME renders as video, and any other MIME
* renders as an image regardless of the URL extension. Only when the MIME is
* absent (legacy events that predate the tag) do we fall back to a path
* extension check.
*/
export function isVideoMedia(src: string, imetaMime?: string): boolean {
if (imetaMime) return imetaMime.toLowerCase().startsWith("video/");
const ext = urlPathExtension(src);
return (
ext !== undefined && (VIDEO_EXTENSIONS as readonly string[]).includes(ext)
);
}
/**
* Whether `src` is a relay-hosted `/media/` URL on `relayOrigin`.
*
* This mirrors the Rust `validate_download_url` origin+path check for UX
* purposes only — the Rust gate remains the authoritative SSRF boundary.
*
* Fails closed when the relay origin has not resolved yet (`relayOrigin`
* absent): an unresolved origin means we can't distinguish a relay `/media/`
* URL from an off-relay one, so we do NOT offer Download (offering it for an
* off-relay URL would only produce an error, and the Rust gate would reject
* it anyway). Callers must read `relayOrigin` from a reactive source
* (`useRelayOrigin`) so eligibility recomputes — and Download appears for a
* genuine relay URL — the moment the origin resolves.
*/
export function isRelayDownloadable(
src: string,
relayOrigin?: string,
): boolean {
if (!relayOrigin) return false;
let parsed: URL;
try {
parsed = new URL(src);
} catch {
return false;
}
if (!parsed.pathname.startsWith("/media/")) return false;
return parsed.origin === relayOrigin;
}
@@ -0,0 +1,83 @@
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,
};
}
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
DEFAULT_VIDEO_FILENAME,
resolveVideoDownloadFilename,
} from "./videoDownload.ts";
test("resolveVideoDownloadFilename: keeps a real imeta filename", () => {
assert.equal(resolveVideoDownloadFilename("clip.webm"), "clip.webm");
});
test("resolveVideoDownloadFilename: trims surrounding whitespace", () => {
assert.equal(resolveVideoDownloadFilename(" demo.mp4 "), "demo.mp4");
});
test("resolveVideoDownloadFilename: falls back when undefined", () => {
assert.equal(resolveVideoDownloadFilename(undefined), DEFAULT_VIDEO_FILENAME);
});
test("resolveVideoDownloadFilename: falls back when empty / whitespace-only", () => {
assert.equal(resolveVideoDownloadFilename(""), DEFAULT_VIDEO_FILENAME);
assert.equal(resolveVideoDownloadFilename(" "), DEFAULT_VIDEO_FILENAME);
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Pure helpers for the inline video right-click menu, kept out of the
* component so the branch logic is unit-testable without a DOM.
*/
/** Fallback save-dialog name when the imeta `filename` field is absent. */
export const DEFAULT_VIDEO_FILENAME = "video.mp4";
/**
* The suggested filename passed to the native `download_file` command.
*
* The relay only stores a content hash in the URL path, so the imeta
* `filename` is the only human-readable name we have. When it is missing we
* fall back to a generic `.mp4` name (the backend re-derives the real
* extension defensively from this value).
*/
export function resolveVideoDownloadFilename(filename?: string): string {
const trimmed = filename?.trim();
return trimmed ? trimmed : DEFAULT_VIDEO_FILENAME;
}
@@ -770,6 +770,14 @@ test("right-click image shows Copy image and invokes copy command", async ({
page.getByRole("button", { name: "Download image" }),
).toBeVisible();
// The image menu carries the shared generic marker plus its own image-
// specific attribute, and never the link/video attributes — so e2e locators
// can target one surface without aliasing another.
await expect(page.locator("[data-media-context-menu]")).toBeVisible();
await expect(page.locator("[data-image-context-menu]")).toBeVisible();
await expect(page.locator("[data-link-context-menu]")).toHaveCount(0);
await expect(page.locator("[data-video-context-menu]")).toHaveCount(0);
await copyButton.click();
await expect
+145
View File
@@ -9,6 +9,14 @@ const PORTRAIT_VIDEO_SHA = "c".repeat(64);
const PORTRAIT_VIDEO_URL = `http://localhost:3000/media/${PORTRAIT_VIDEO_SHA}.mp4`;
const CONSTRAINED_LANDSCAPE_VIDEO_SHA = "d".repeat(64);
const CONSTRAINED_LANDSCAPE_VIDEO_URL = `http://localhost:3000/media/${CONSTRAINED_LANDSCAPE_VIDEO_SHA}.mp4`;
// Distinct-selector context-menu probes. The relay video is on the resolved
// relay origin (localhost:3000) so it is Download-eligible once the origin
// resolves; the off-relay video shares an identical /media/ path shape on a
// different origin, so it renders and offers Copy link but never Download.
const MENU_RELAY_VIDEO_SHA = "e".repeat(64);
const MENU_RELAY_VIDEO_URL = `http://localhost:3000/media/${MENU_RELAY_VIDEO_SHA}.mp4`;
const MENU_OFF_RELAY_VIDEO_SHA = "f".repeat(64);
const MENU_OFF_RELAY_VIDEO_URL = `https://cdn.example.com/media/${MENU_OFF_RELAY_VIDEO_SHA}.mp4`;
const VIDEO_REVIEW_NEUTRAL_ACCENT = "neutral";
const VIDEO_REVIEW_LIGHT_THEME = "catppuccin-latte";
// The fresh-profile default is the Buzz theme, which pins the neutral accent
@@ -892,3 +900,140 @@ test("dark accent uses a contrast-safe review foreground", async ({ page }) => {
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveCSS("color", VIDEO_REVIEW_INDIGO_FOREGROUND_RGB);
});
// Emit a video message with the imeta the renderer needs to pick the video
// player and, for the relay case, a resolvable download URL.
function emitVideoMessage(
page: Page,
{ url, sha, filename }: { url: string; sha: string; filename: string },
) {
return emitMockMessage(page, "general", `![video](${url})`, {
extraTags: [
[
"imeta",
`url ${url}`,
"m video/mp4",
`x ${sha}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
`filename ${filename}`,
],
],
});
}
test("right-click menus expose distinct selectors for links, relay video, and off-relay video", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
// ── Link menu: Open link + Copy link, never image/video attributes ────────
await emitMockMessage(
page,
"general",
"Docs at https://example.com/handbook",
);
const link = page.getByRole("link", { name: "https://example.com/handbook" });
await expect(link).toBeVisible();
await link.scrollIntoViewIfNeeded();
// Fire a real bubbling `contextmenu` MouseEvent on the anchor rather than
// `.click({ button: "right" })`: the link is wrapped in a hover tooltip and a
// positional right-click can land on the tooltip layer (or merely select the
// text) without reaching the anchor's React `onContextMenuCapture`. An
// element-targeted bubbling event drives the capture handler deterministically.
await link.evaluate((el) =>
el.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true }),
),
);
const linkMenu = page.locator("[data-link-context-menu]");
await expect(linkMenu).toBeVisible();
await expect(page.locator("[data-media-context-menu]")).toBeVisible();
await expect(
linkMenu.getByRole("button", { name: "Open link" }),
).toBeVisible();
await expect(
linkMenu.getByRole("button", { name: "Copy link" }),
).toBeVisible();
await expect(page.locator("[data-image-context-menu]")).toHaveCount(0);
await expect(page.locator("[data-video-context-menu]")).toHaveCount(0);
// Dismiss the menu before the next probe: the menu closes on any click.
await page.getByTestId("chat-title").click();
await expect(linkMenu).toHaveCount(0);
// ── Relay video menu: Download video + Copy link, appearing only once the
// relay origin resolves (the reactivity fix) ─────────────────────────────
await emitVideoMessage(page, {
url: MENU_RELAY_VIDEO_URL,
sha: MENU_RELAY_VIDEO_SHA,
filename: "relay-clip.mp4",
});
const relayPlayer = page.getByTestId("video-player").last();
await expect(relayPlayer).toBeVisible();
// Right-click the player surface. `force` skips the actionability guard: the
// Play-button overlay sits above the video, but the contextmenu event still
// capture-bubbles to the surface handler that opens the menu.
await relayPlayer.click({ button: "right", force: true });
const videoMenu = page.locator("[data-video-context-menu]");
await expect(videoMenu).toBeVisible();
await expect(page.locator("[data-media-context-menu]")).toBeVisible();
// Download eligibility is reactive: the relay origin resolves asynchronously
// (commonly after first render), and when it does the already-open menu
// recomputes to reveal Download — no re-navigation or menu re-open.
await expect(
videoMenu.getByRole("button", { name: "Download video" }),
).toBeVisible();
await expect(
videoMenu.getByRole("button", { name: "Copy link" }),
).toBeVisible();
await expect(page.locator("[data-image-context-menu]")).toHaveCount(0);
await expect(page.locator("[data-link-context-menu]")).toHaveCount(0);
// The Download action drives the native download_file command.
await videoMenu.getByRole("button", { name: "Download video" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_file");
// Dismiss the relay menu before the off-relay probe so `[data-video-context
// -menu]` refers unambiguously to the off-relay video's menu below. The menu
// closes on any click.
await page.getByTestId("chat-title").click();
await expect(page.locator("[data-video-context-menu]")).toHaveCount(0);
// ── Off-relay video control: renders and offers Copy link, never Download ─
await emitVideoMessage(page, {
url: MENU_OFF_RELAY_VIDEO_URL,
sha: MENU_OFF_RELAY_VIDEO_SHA,
filename: "external-clip.mp4",
});
const offRelayPlayer = page.getByTestId("video-player").last();
await expect(offRelayPlayer).toBeVisible();
await offRelayPlayer.click({ button: "right", force: true });
const offRelayMenu = page.locator("[data-video-context-menu]");
await expect(offRelayMenu).toBeVisible();
await expect(
offRelayMenu.getByRole("button", { name: "Copy link" }),
).toBeVisible();
// An off-relay video would fail the download command's SSRF gate, so the
// menu must never offer Download for it.
await expect(
offRelayMenu.getByRole("button", { name: "Download video" }),
).toHaveCount(0);
});