diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38fb46f..347ff68 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -283,18 +283,54 @@ async fn test_deno(app_handle: tauri::AppHandle) -> Result { } } +fn is_safe_path(path: &std::path::Path) -> bool { + !path.components().any(|c| matches!(c, std::path::Component::ParentDir)) +} + #[tauri::command] async fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> { println!("open_file called for path: {}", path); use tauri_plugin_opener::OpenerExt; - app.opener().open_path(&path, None::).map_err(|e| format!("Failed to open file: {}", e)) + + let mut resolved_dest = std::path::PathBuf::from(&path); + if path.starts_with("~/") { + if let Ok(home) = app.path().home_dir() { + resolved_dest = home.join(&path[2..]); + } + } else if path == "~" { + if let Ok(home) = app.path().home_dir() { + resolved_dest = home; + } + } + + if !is_safe_path(&resolved_dest) { + return Err("Path traversal blocked".to_string()); + } + + app.opener().open_path(resolved_dest.to_string_lossy().as_ref(), None::).map_err(|e| format!("Failed to open file: {}", e)) } #[tauri::command] async fn show_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> { println!("show_in_folder called for path: {}", path); use tauri_plugin_opener::OpenerExt; - app.opener().reveal_item_in_dir(&path).map_err(|e| format!("Failed to reveal in folder: {}", e)) + + let mut resolved_dest = std::path::PathBuf::from(&path); + if path.starts_with("~/") { + if let Ok(home) = app.path().home_dir() { + resolved_dest = home.join(&path[2..]); + } + } else if path == "~" { + if let Ok(home) = app.path().home_dir() { + resolved_dest = home; + } + } + + if !is_safe_path(&resolved_dest) { + return Err("Path traversal blocked".to_string()); + } + + app.opener().reveal_item_in_dir(resolved_dest.to_string_lossy().as_ref()).map_err(|e| format!("Failed to reveal in folder: {}", e)) } use std::sync::atomic::{AtomicBool, Ordering}; @@ -538,8 +574,54 @@ async fn start_download( resolved_dest = home; } } - let _ = connections; - let _ = checksum; + + if !is_safe_path(&resolved_dest) { + return Err(AppError::Internal("Path traversal blocked".to_string())); + } + + if connections.unwrap_or(1) > 1 || checksum.is_some() { + println!("Routing multi-part/checksum download to aria2: {}", id); + let mut options = serde_json::Map::new(); + options.insert("dir".to_string(), serde_json::json!(resolved_dest.to_string_lossy().to_string())); + options.insert("out".to_string(), serde_json::json!(filename)); + if let Some(conn) = connections { + options.insert("split".to_string(), serde_json::json!(conn.to_string())); + options.insert("max-connection-per-server".to_string(), serde_json::json!(conn.to_string())); + } + if let Some(speed) = speed_limit { + options.insert("max-download-limit".to_string(), serde_json::json!(speed)); + } + if let Some(user) = username { + options.insert("http-user".to_string(), serde_json::json!(user)); + } + if let Some(pass) = password { + options.insert("http-passwd".to_string(), serde_json::json!(pass)); + } + if let Some(chk) = checksum { + options.insert("checksum".to_string(), serde_json::json!(chk)); + } + if let Some(ua) = user_agent { + options.insert("user-agent".to_string(), serde_json::json!(ua)); + } + if let Some(prox) = proxy { + options.insert("all-proxy".to_string(), serde_json::json!(prox)); + } + if let Some(cook) = cookies { + options.insert("header".to_string(), serde_json::json!(format!("Cookie: {}", cook))); + } + + let params = serde_json::json!([ + [url], + options + ]); + + rpc_call(state.aria2_port, &state.aria2_secret, "aria2.addUri", params) + .await + .map(|_| ()) + .map_err(|e| AppError::Internal(e))?; + + return Ok(()); + } state .download_coordinator .send(download::DownloadCmd::Start(download::DownloadPayload { @@ -927,25 +1009,29 @@ fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), Stri #[tauri::command] async fn set_concurrent_limit(state: tauri::State<'_, AppState>, limit: usize) -> Result<(), String> { - let _ = rpc_call( + rpc_call( state.aria2_port, &state.aria2_secret, "aria2.changeGlobalOption", serde_json::json!([{"max-concurrent-downloads": limit.to_string()}]) - ).await; - Ok(()) + ).await.map(|_| ()).map_err(|e| { + eprintln!("Failed to set concurrent limit: {}", e); + e + }) } #[tauri::command] async fn set_global_speed_limit(state: tauri::State<'_, AppState>, limit: Option) -> Result<(), String> { let limit_str = limit.unwrap_or_else(|| "0".to_string()); - let _ = rpc_call( + rpc_call( state.aria2_port, &state.aria2_secret, "aria2.changeGlobalOption", serde_json::json!([{"max-overall-download-limit": limit_str}]) - ).await; - Ok(()) + ).await.map(|_| ()).map_err(|e| { + eprintln!("Failed to set global speed limit: {}", e); + e + }) } #[tauri::command] @@ -957,16 +1043,18 @@ fn request_automation_permission() -> Result<(), String> { use objc::{msg_send, sel, sel_impl, class}; unsafe { - let script_str = NSString::alloc(nil).init_str("tell application \"Finder\" to get name"); - let ns_apple_script: id = msg_send![class!(NSAppleScript), alloc]; - let ns_apple_script: id = msg_send![ns_apple_script, initWithSource: script_str]; - let mut error_dict: id = nil; - let result: id = msg_send![ns_apple_script, executeAndReturnError: &mut error_dict]; - if result == nil { - return Err("Automation permission was not granted".to_string()); - } + objc::rc::autoreleasepool(|| { + let script_str = NSString::alloc(nil).init_str("tell application \"Finder\" to get name"); + let ns_apple_script: id = msg_send![class!(NSAppleScript), alloc]; + let ns_apple_script: id = msg_send![ns_apple_script, initWithSource: script_str]; + let mut error_dict: id = nil; + let result: id = msg_send![ns_apple_script, executeAndReturnError: &mut error_dict]; + if result == nil { + return Err("Automation permission was not granted".to_string()); + } + Ok(()) + }) } - return Ok(()); } #[cfg(not(target_os = "macos"))] @@ -1067,6 +1155,9 @@ fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { resolved_dest = home; } } + if !is_safe_path(&resolved_dest) { + return false; + } resolved_dest.exists() } @@ -1083,6 +1174,9 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> resolved_dest = home; } } + if !is_safe_path(&resolved_dest) { + return Err("Path traversal blocked".to_string()); + } if resolved_dest.exists() { std::fs::remove_file(resolved_dest).map_err(|e| e.to_string()) } else { diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index e469ac5..29f91df 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -8,9 +8,8 @@ pub async fn get_system_proxy() -> Result, String> { match sysproxy::Sysproxy::get_system_proxy() { Ok(proxy) => { if proxy.enable { - // Determine protocol, usually sysproxy returns the host and port - // We'll default to http:// unless the user has configured something specific - Ok(Some(format!("http://{}:{}", proxy.host, proxy.port))) + let protocol = if proxy.host.contains("://") { "" } else { "http://" }; + Ok(Some(format!("{}{}:{}", protocol, proxy.host, proxy.port))) } else { Ok(None) } diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index b568569..a07e6ed 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -317,6 +317,7 @@ export const AddDownloadsModal = () => { // Metadata parser useEffect(() => { + let active = true; const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0); // Immediately display items in loading state @@ -340,6 +341,7 @@ export const AddDownloadsModal = () => { let firstReadyIndex: number | null = null; for (let i = 0; i < lines.length; i++) { + if (!active) break; const url = lines[i]; try { new URL(url); @@ -408,15 +410,18 @@ export const AddDownloadsModal = () => { console.error("Meta fetch failed", e); updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' }; } - setParsedItems([...updatedItems]); + if (active) setParsedItems([...updatedItems]); } - if (firstReadyIndex !== null) { + if (active && firstReadyIndex !== null) { setSelectedItemIndex(firstReadyIndex); } }, 400); - return () => clearTimeout(timer); + return () => { + active = false; + clearTimeout(timer); + }; }, [urls, pendingAddFilename]); if (!isAddModalOpen) return null; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 46c094e..c3d9ae8 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -245,6 +245,11 @@ export default function SettingsView() { type="number" min="1" max="16" value={settings.perServerConnections} onChange={(e) => settings.setPerServerConnections(Number(e.target.value))} + onBlur={(e) => { + const val = Number(e.target.value); + if (val < 1) settings.setPerServerConnections(1); + if (val > 16) settings.setPerServerConnections(16); + }} className="app-control w-24 text-center" /> @@ -257,6 +262,11 @@ export default function SettingsView() { type="number" min="1" max="12" value={settings.maxConcurrentDownloads} onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))} + onBlur={(e) => { + const val = Number(e.target.value); + if (val < 1) settings.setMaxConcurrentDownloads(1); + if (val > 12) settings.setMaxConcurrentDownloads(12); + }} className="app-control w-24 text-center" /> @@ -285,6 +295,11 @@ export default function SettingsView() { type="number" min="0" max="10" value={settings.maxAutomaticRetries} onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))} + onBlur={(e) => { + const val = Number(e.target.value); + if (val < 0) settings.setMaxAutomaticRetries(0); + if (val > 10) settings.setMaxAutomaticRetries(10); + }} className="app-control w-24 text-center" /> @@ -453,9 +468,14 @@ export default function SettingsView() {
Proxy Port settings.setProxyPort(Number(e.target.value))} + onBlur={(e) => { + const val = Number(e.target.value); + if (val < 1) settings.setProxyPort(1); + if (val > 65535) settings.setProxyPort(65535); + }} className="app-control w-24 text-center" />
diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 4a0f762..1328a20 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -9,6 +9,7 @@ import { useSettingsStore } from './useSettingsStore'; export type { DownloadCategory } from '../utils/downloads'; const getProxyArgs = (settings: ReturnType) => { + if (settings.proxyMode === 'system') return 'system'; if (settings.proxyMode === 'custom' && settings.proxyHost) { return `http://${settings.proxyHost}:${settings.proxyPort}`; } @@ -25,7 +26,8 @@ export const getSiteLogin = (url: string, settings: ReturnType((set, get) => ({ }, redownload: (id) => { let updatedItem: DownloadItem | null = null; + let wasDownloading = false; set((state) => ({ downloads: state.downloads.map(d => { if (d.id === id) { + if (d.status === 'downloading') { + wasDownloading = true; + } const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' }; updatedItem = updated; return updated; @@ -201,6 +207,9 @@ export const useDownloadStore = create((set, get) => ({ return d; }) })); + if (wasDownloading) { + invoke('pause_download', { id }).catch(console.error); + } if (updatedItem) { const toSave = { ...(updatedItem as DownloadItem) }; delete toSave.fraction; delete toSave.speed; delete toSave.eta; diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index a38308c..4626667 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -11,8 +11,10 @@ const MEDIA_DOMAINS = [ 'instagram.com', 'tiktok.com', 'reddit.com', + 'v.redd.it', 'soundcloud.com', - 'facebook.com' + 'facebook.com', + 'fb.watch' ]; export const categoryForFileName = (fileName: string): DownloadCategory => {