diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f554ce967..02c2336a0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ use managed_agents::{ find_managed_agent_mut, load_managed_agents, save_managed_agents, start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes, BackendKind, }; -use tauri::{Manager, RunEvent}; +use tauri::{http, Manager, RunEvent}; use tauri_plugin_window_state::StateFlags; fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String> { @@ -86,6 +86,75 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { Ok(()) } +/// Proxy media requests through the Rust backend so they traverse the WARP tunnel. +/// +/// WKWebView's networking stack bypasses WARP, causing 403s from Cloudflare Access. +/// This handler routes `sprout-media://localhost/{path}` through reqwest, which +/// runs in the Tauri process and goes through WARP. +async fn handle_sprout_media( + app: &tauri::AppHandle, + request: &http::Request>, +) -> http::Response> { + let state = app.state::(); + let base = relay::relay_api_base_url(); + + // Preserve path + query (thumbnails may have query params). + // Only proxy /media/ paths — reject anything else. + let path_and_query = request + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + + if !path_and_query.starts_with("/media/") { + return error_response(404, "not found"); + } + + let upstream_url = format!("{base}{path_and_query}"); + + let result = state + .http_client + .get(&upstream_url) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await; + + match result { + Ok(resp) => { + let status = resp.status().as_u16(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + match resp.bytes().await { + Ok(bytes) => http::Response::builder() + .status(status) + .header("content-type", &content_type) + .body(bytes.to_vec()) + .unwrap_or_else(|_| error_response(500, "response build failed")), + Err(_) => error_response(502, "failed to read upstream body"), + } + } + Err(_) => error_response(502, "upstream request failed"), + } +} + +fn error_response(status: u16, msg: &str) -> http::Response> { + http::Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(msg.as_bytes().to_vec()) + .unwrap_or_else(|_| { + http::Response::builder() + .status(500) + .body(Vec::new()) + .unwrap() + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let app = tauri::Builder::default() @@ -98,6 +167,13 @@ pub fn run() { ) .plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_dialog::init()) + .register_asynchronous_uri_scheme_protocol("sprout-media", |ctx, request, responder| { + let app = ctx.app_handle().clone(); + tauri::async_runtime::spawn(async move { + let response = handle_sprout_media(&app, &request).await; + responder.respond(response); + }); + }) .manage(build_app_state()) .setup(|app| { let app_handle = app.handle().clone(); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 8e39ebc63..873cf548c 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -6,6 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import { MessageActionBar } from "./MessageActionBar"; @@ -172,7 +173,7 @@ export const MessageRow = React.memo( setHasAvatarError(true); }} referrerPolicy="no-referrer" - src={message.avatarUrl} + src={rewriteRelayUrl(message.avatarUrl)} /> ) : (
) : (
); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 68a54244e..eb6766855 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { useUserProfileQuery } from "@/features/profile/hooks"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { usePresenceQuery } from "@/features/presence/hooks"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; @@ -42,7 +43,7 @@ export function UserProfilePopover({ alt={profile.displayName ?? "User avatar"} className="h-10 w-10 shrink-0 rounded-xl object-cover shadow-sm" referrerPolicy="no-referrer" - src={profile.avatarUrl} + src={rewriteRelayUrl(profile.avatarUrl)} /> ) : (
diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts new file mode 100644 index 000000000..be5209126 --- /dev/null +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -0,0 +1,28 @@ +/** + * Rewrite relay media URLs to use the sprout-media:// custom protocol. + * + * WKWebView's networking stack bypasses WARP, so direct requests + * to the relay get 403'd by Cloudflare Access. The sprout-media:// scheme + * routes fetches through the Rust backend, which goes through WARP. + * + * Detection is path-based: /media/{64-hex-chars}.{ext} is a Blossom BUD-01 + * content-addressed URL. The 64-char lowercase hex SHA-256 hash makes this + * pattern unique to Blossom relays — false positives from other origins are + * practically impossible. This avoids needing async relay-URL initialization, + * eliminating race conditions with first render. + */ + +// 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)(?:\?.*)?)$/; + +/** + * If `url` looks like a Blossom relay media URL, rewrite it to go through + * the sprout-media:// custom protocol. Otherwise return it unchanged. + */ +export function rewriteRelayUrl(url: string): string { + const m = RELAY_MEDIA_RE.exec(url); + if (!m) return url; + return `sprout-media://localhost/media/${m[1]}`; +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 66d7975f9..92f2581ad 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -4,6 +4,7 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import remarkChannelLinks from "@/shared/lib/remarkChannelLinks"; import remarkMentions from "@/shared/lib/remarkMentions"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; @@ -106,7 +107,7 @@ function createMarkdownComponents( {alt} ), li: ({ children }) =>
  • {children}
  • ,