Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion desktop/src-tauri/src/commands/export_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,24 @@ pub async fn save_json_with_dialog(
app: &AppHandle,
suggested_filename: &str,
data: &[u8],
) -> Result<bool, String> {
save_bytes_with_dialog(app, suggested_filename, "JSON", &["json"], data).await
}

/// Show a save-file dialog with a custom filter and write `data` to the chosen
/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the
/// user cancelled the dialog.
pub async fn save_bytes_with_dialog(
app: &AppHandle,
suggested_filename: &str,
filter_name: &str,
extensions: &[&str],
data: &[u8],
) -> Result<bool, String> {
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter("JSON", &["json"])
.add_filter(filter_name, extensions)
.set_file_name(suggested_filename)
.save_file(move |path| {
let _ = tx.send(path);
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const ALLOWED_MIME: &[&str] = &[
"video/mp4",
];

fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
let mime = infer::get(body)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
Expand Down
207 changes: 207 additions & 0 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
use futures_util::StreamExt;
use tauri::State;

use crate::app_state::AppState;
use crate::commands::export_util::save_bytes_with_dialog;
use crate::relay::relay_api_base_url_with_override;

use super::media::detect_and_validate_mime;

/// Maximum download size: 50 MiB. Prevents OOM from oversized responses.
const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;

/// Download request timeout.
const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);

/// Validate that a URL is a legitimate relay media URL.
///
/// Ensures:
/// - URL scheme is `https` (or `http` for localhost dev)
/// - URL origin matches the relay base URL
/// - URL path matches `/media/{hash}.{ext}`
fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> {
let parsed = url::Url::parse(url).map_err(|_| "invalid URL".to_string())?;
let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL".to_string())?;

// Scheme must be https (allow http for localhost dev servers).
match parsed.scheme() {
"https" => {}
"http" => {
let host = parsed.host_str().unwrap_or("");
if host != "localhost" && host != "127.0.0.1" && host != "[::1]" {
return Err("download URL must use HTTPS".to_string());
}
}
_ => return Err("download URL must use HTTPS".to_string()),
}

// Origin must match relay.
if parsed.origin() != base.origin() {
return Err("download URL must match the relay origin".to_string());
}

// Path must be /media/{filename}.
let path = parsed.path();
if !path.starts_with("/media/") {
return Err("download URL must be a /media/ path".to_string());
}

Ok(())
}

/// Download an image from a URL and save it via a native save-file dialog.
#[tauri::command]
pub async fn download_image(
url: String,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
// SSRF protection: only allow downloads from the relay's /media/ path.
let relay_base = relay_api_base_url_with_override(&state);
validate_download_url(&url, &relay_base)?;

// Infer filename from the URL path (e.g. "abcdef123.jpg" from a Blossom URL).
let filename = url::Url::parse(&url)
.ok()
.and_then(|u| {
u.path_segments()?
.last()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "image.png".to_string());

// Derive extension for the save dialog filter.
let ext = std::path::Path::new(&filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_string();

// Fetch image bytes via the app's HTTP client (goes through WARP tunnel).
let resp = state
.http_client
.get(&url)
.timeout(DOWNLOAD_TIMEOUT)
.send()
.await
.map_err(|e| format!("download failed: {e}"))?;

if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("download failed ({status}): {text}"));
}

// Check Content-Length header upfront if present.
if let Some(content_length) = resp.content_length() {
if content_length > MAX_DOWNLOAD_BYTES {
return Err(format!(
"file too large ({} MiB, max {} MiB)",
content_length / (1024 * 1024),
MAX_DOWNLOAD_BYTES / (1024 * 1024)
));
}
}

// Stream the response with a running byte count to enforce the size cap
// even when Content-Length is missing or dishonest.
let mut bytes = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("download stream error: {e}"))?;
if bytes.len() as u64 + chunk.len() as u64 > MAX_DOWNLOAD_BYTES {
return Err(format!(
"file too large (max {} MiB)",
MAX_DOWNLOAD_BYTES / (1024 * 1024)
));
}
bytes.extend_from_slice(&chunk);
}

// Validate the downloaded content is actually a supported media type.
detect_and_validate_mime(&bytes)?;

save_bytes_with_dialog(&app, &filename, "Images", &[&ext], &bytes).await
}

#[cfg(test)]
mod tests {
use super::*;

const RELAY_BASE: &str = "https://relay.example.com";

#[test]
fn test_validate_download_url_valid_relay_url() {
assert!(validate_download_url(
"https://relay.example.com/media/abcdef1234567890.jpg",
RELAY_BASE,
)
.is_ok());
}

#[test]
fn test_validate_download_url_valid_relay_url_png() {
assert!(
validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,)
.is_ok()
);
}

#[test]
fn test_validate_download_url_non_relay_origin_rejected() {
let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE);
assert!(result.is_err());
assert!(result.unwrap_err().contains("relay origin"));
}

#[test]
fn test_validate_download_url_private_ip_rejected() {
let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE);
assert!(result.is_err());
}

#[test]
fn test_validate_download_url_loopback_rejected() {
let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE);
assert!(result.is_err());
assert!(result.unwrap_err().contains("relay origin"));
}

#[test]
fn test_validate_download_url_localhost_allowed_for_localhost_relay() {
assert!(validate_download_url(
"http://localhost:3000/media/abc.jpg",
"http://localhost:3000",
)
.is_ok());
}

#[test]
fn test_validate_download_url_missing_media_path_rejected() {
let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE);
assert!(result.is_err());
assert!(result.unwrap_err().contains("/media/"));
}

#[test]
fn test_validate_download_url_non_https_scheme_rejected() {
let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE);
assert!(result.is_err());
assert!(result.unwrap_err().contains("HTTPS"));
}

#[test]
fn test_validate_download_url_http_non_localhost_rejected() {
let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE);
assert!(result.is_err());
assert!(result.unwrap_err().contains("HTTPS"));
}

#[test]
fn test_validate_download_url_root_path_rejected() {
let result = validate_download_url("https://relay.example.com/", RELAY_BASE);
assert!(result.is_err());
assert!(result.unwrap_err().contains("/media/"));
}
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod dms;
mod export_util;
mod identity;
mod media;
mod media_download;
mod messages;
pub mod pairing;
mod personas;
Expand All @@ -30,6 +31,7 @@ pub use channels::*;
pub use dms::*;
pub use identity::*;
pub use media::*;
pub use media_download::*;
pub use messages::*;
pub use pairing::*;
pub use personas::*;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ pub fn run() {
upload_media,
pick_and_upload_media,
upload_media_bytes,
download_image,
list_relay_members,
get_my_relay_membership,
add_relay_member,
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,23 @@ export function AppShell() {
},
);

// Prevent webview file:/// navigation on file drop outside the composer.
// Scoped to file drags only (text drag-and-drop into inputs still works).
// Composer's onDrop fires first (React synthetic before window bubble).
React.useEffect(() => {
function preventNavigation(e: DragEvent) {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
}
}
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
};
}, []);

React.useEffect(() => {
let isCancelled = false;

Expand Down
8 changes: 7 additions & 1 deletion desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";
import {
ComposerAttachments,
DropZoneOverlay,
} from "@/features/messages/ui/ComposerAttachments";
import {
MentionAutocomplete,
type MentionSuggestion,
Expand Down Expand Up @@ -334,12 +337,15 @@ export function ForumComposer({
return (
<form
className="relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4"
onDragEnter={media.handleDragEnter}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
}}
onSubmit={handleSubmit}
>
{media.isDragOver && <DropZoneOverlay />}
<ChannelAutocomplete
onSelect={applyChannelInsert}
position={autocompletePosition}
Expand Down
Loading
Loading