diff --git a/package.json b/package.json index cb3cba4..20ff70a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", - "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-updater": "^2.10.0", "@tiptap/core": "^3.19.0", "@tiptap/extension-code-block-lowlight": "^3.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42f6b33..2ceaf7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@tauri-apps/plugin-fs': specifier: ^2.4.5 version: 2.4.5 - '@tauri-apps/plugin-opener': - specifier: ^2.5.3 - version: 2.5.3 '@tauri-apps/plugin-updater': specifier: ^2.10.0 version: 2.10.0 @@ -741,9 +738,6 @@ packages: '@tauri-apps/plugin-fs@2.4.5': resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==} - '@tauri-apps/plugin-opener@2.5.3': - resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} - '@tauri-apps/plugin-updater@2.10.0': resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} @@ -2178,10 +2172,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-opener@2.5.3': - dependencies: - '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-updater@2.10.0': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2f64537..07c0746 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -24,9 +24,6 @@ "dialog:allow-message", "dialog:allow-ask", "dialog:allow-confirm", - "fs:default", - "fs:allow-read", - "fs:allow-read-file", - "opener:allow-reveal-item-in-dir" + "fs:allow-read-file" ] } diff --git a/src-tauri/src/asset_scope.rs b/src-tauri/src/asset_scope.rs index 95b1521..0425cfc 100644 --- a/src-tauri/src/asset_scope.rs +++ b/src-tauri/src/asset_scope.rs @@ -21,6 +21,25 @@ pub fn allow_vault_assets(app: &AppHandle, vault_path: &Path) -> .map_err(|error| error.to_string()) } +/// Grants access to relative assets next to a Markdown file explicitly opened by the user. +pub fn allow_external_note_assets( + app: &AppHandle, + note_path: &Path, +) -> Result<(), String> { + let note = std::fs::canonicalize(note_path).map_err(|error| { + format!( + "Failed to resolve external note path '{}': {error}", + note_path.display() + ) + })?; + let parent = note + .parent() + .ok_or_else(|| "External note has no parent directory".to_string())?; + app.asset_protocol_scope() + .allow_directory(parent, true) + .map_err(|error| error.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -65,4 +84,28 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + + #[test] + fn external_note_scope_allows_relative_assets_only_below_its_directory() { + let root = test_directory("external-note-assets"); + let note_dir = root.join("shared note"); + let assets = note_dir.join("images"); + fs::create_dir_all(&assets).unwrap(); + let note = note_dir.join("note.md"); + let image = assets.join("image.png"); + let outside = root.join("outside.png"); + fs::write(¬e, b"note").unwrap(); + fs::write(&image, b"image").unwrap(); + fs::write(&outside, b"outside").unwrap(); + + let app = tauri::test::mock_app(); + allow_external_note_assets(app.handle(), ¬e).unwrap(); + let scope = app.asset_protocol_scope(); + + assert!(scope.is_allowed(¬e)); + assert!(scope.is_allowed(&image)); + assert!(!scope.is_allowed(&outside)); + + fs::remove_dir_all(root).unwrap(); + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8ed4dd1..599e4aa 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,8 +3,9 @@ use crate::search::SearchIndex; use crate::state::AppState; use crate::types::*; use crate::vault::{operations, watcher}; -use std::path::Path; -use tauri::{AppHandle, Manager, State}; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager, Runtime, State}; +use tauri_plugin_fs::FsExt; fn index_note_bg(state: &State<'_, AppState>, path: &str) { let search = state.search_index.lock().ok().and_then(|g| g.clone()); @@ -415,6 +416,7 @@ mod custom_theme_reference_tests { #[tauri::command] pub fn export_custom_theme( + app: AppHandle, state: State<'_, AppState>, id: String, path: String, @@ -427,15 +429,18 @@ pub fn export_custom_theme( .ok_or_else(|| "Theme not found".to_string())?; let export = serde_json::json!({ "version": 1, "themes": [theme] }); let data = serde_json::to_string_pretty(&export).map_err(|e| e.to_string())?; + ensure_scoped_path(&app, Path::new(&path))?; std::fs::write(&path, data).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn import_custom_themes( + app: AppHandle, state: State<'_, AppState>, path: String, ) -> Result, String> { + ensure_scoped_path(&app, Path::new(&path))?; let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; let themes: Vec = @@ -1468,7 +1473,8 @@ pub fn read_clipboard_image() -> Result, String> { /// Copy an image file to the system clipboard. #[cfg(desktop)] #[tauri::command] -pub fn copy_image_to_clipboard(path: String) -> Result<(), String> { +pub fn copy_image_to_clipboard(app: AppHandle, path: String) -> Result<(), String> { + ensure_readable_path(&app, Path::new(&path))?; let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?; let img = image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?; @@ -1489,7 +1495,7 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> { #[cfg(mobile)] #[tauri::command] -pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> { +pub fn copy_image_to_clipboard(_app: AppHandle, _path: String) -> Result<(), String> { Err("Clipboard image copy not supported on Android".to_string()) } @@ -1914,70 +1920,203 @@ fn percent_decode(s: &str) -> String { result } -// ── Open file/URL with system default handler ── +// ── Open files and URLs with the system default handler ── -fn xdg_open(arg: &str) -> Result<(), String> { - #[cfg(target_os = "linux")] - { - let mut cmd = std::process::Command::new("xdg-open"); - cmd.arg(arg); - // Clear AppImage environment so child processes find host binaries - // (e.g. gio-launch-desktop on GNOME) - if std::env::var("APPIMAGE").is_ok() { - cmd.env_remove("LD_LIBRARY_PATH") - .env_remove("LD_PRELOAD") - .env_remove("GIO_LAUNCHED_DESKTOP_FILE") - .env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); - if let Ok(original_path) = std::env::var("PATH_ORIG") { - cmd.env("PATH", original_path); - } +fn active_vault_path(app: &AppHandle) -> Result { + let state = app.state::(); + let config = state.config.lock().map_err(|error| error.to_string())?; + config + .active_vault + .as_deref() + .map(PathBuf::from) + .ok_or_else(|| "No active vault".to_string()) +} + +fn path_is_in_active_vault(app: &AppHandle, path: &Path) -> bool { + let Ok(vault) = active_vault_path(app) + .and_then(|path| std::fs::canonicalize(path).map_err(|error| error.to_string())) + else { + return false; + }; + let canonical = if path.exists() { + std::fs::canonicalize(path).ok() + } else { + path.parent() + .and_then(|parent| std::fs::canonicalize(parent).ok()) + }; + canonical.is_some_and(|path| path.starts_with(vault)) +} + +fn ensure_scoped_path(app: &AppHandle, path: &Path) -> Result<(), String> { + if app.fs_scope().is_allowed(path) { + Ok(()) + } else { + Err("Path was not selected by the user".to_string()) + } +} + +fn ensure_readable_path(app: &AppHandle, path: &Path) -> Result<(), String> { + if path.is_file() && (path_is_in_active_vault(app, path) || app.fs_scope().is_allowed(path)) { + Ok(()) + } else { + Err("File must be inside the active vault or selected by the user".to_string()) + } +} + +fn ensure_writable_path(app: &AppHandle, path: &Path) -> Result<(), String> { + if path_is_in_active_vault(app, path) || app.fs_scope().is_allowed(path) { + Ok(()) + } else { + Err("Destination must be inside the active vault or selected by the user".to_string()) + } +} + +fn validate_external_url(url: &str) -> Result<(), String> { + if url.chars().any(char::is_whitespace) { + return Err("URL must not contain whitespace".to_string()); + } + let parsed = reqwest::Url::parse(url).map_err(|_| "Invalid URL".to_string())?; + match parsed.scheme() { + "http" | "https" | "mailto" | "tel" | "sms" => Ok(()), + _ => Err("Unsupported URL scheme".to_string()), + } +} + +#[cfg(target_os = "linux")] +fn open_linux(argument: &std::ffi::OsStr) -> Result<(), String> { + let mut command = std::process::Command::new("xdg-open"); + command.arg(argument); + if std::env::var("APPIMAGE").is_ok() { + command + .env_remove("LD_LIBRARY_PATH") + .env_remove("LD_PRELOAD") + .env_remove("GIO_LAUNCHED_DESKTOP_FILE") + .env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + if let Ok(original_path) = std::env::var("PATH_ORIG") { + command.env("PATH", original_path); } - cmd.spawn() - .map_err(|e| format!("Failed to open {}: {}", arg, e))?; } - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg(arg) - .spawn() - .map_err(|e| format!("Failed to open {}: {}", arg, e))?; + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Failed to open item: {error}")) +} + +fn open_path_with_system(path: &Path) -> Result<(), String> { + #[cfg(target_os = "linux")] + return open_linux(path.as_os_str()); + + #[cfg(not(target_os = "linux"))] + tauri_plugin_opener::open_path(path, None::<&str>).map_err(|error| error.to_string()) +} + +fn open_url_with_system(url: &str) -> Result<(), String> { + #[cfg(target_os = "linux")] + return open_linux(std::ffi::OsStr::new(url)); + + #[cfg(not(target_os = "linux"))] + tauri_plugin_opener::open_url(url, None::<&str>).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod external_access_tests { + use super::{ensure_readable_path, ensure_writable_path, validate_external_url}; + use crate::state::AppState; + use crate::types::AppConfig; + use std::fs; + use tauri_plugin_fs::FsExt; + + #[test] + fn external_urls_allow_supported_schemes() { + for url in [ + "https://helixnotes.com", + "http://example.com", + "mailto:hello@example.com", + "tel:+123456789", + "sms:+123456789", + ] { + assert!(validate_external_url(url).is_ok(), "{url}"); + } } - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - // CREATE_NO_WINDOW: open the URL/path without flashing a console window. - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - std::process::Command::new("cmd") - .args(["/C", "start", "", arg]) - .creation_flags(CREATE_NO_WINDOW) - .spawn() - .map_err(|e| format!("Failed to open {}: {}", arg, e))?; + + #[test] + fn external_urls_reject_commands_and_unsupported_schemes() { + for url in [ + "https://example.com & calc.exe", + "file:///etc/passwd", + "javascript:alert(1)", + "not a URL", + ] { + assert!(validate_external_url(url).is_err(), "{url}"); + } } - #[cfg(mobile)] - { - let _ = arg; + + #[test] + fn file_access_requires_the_active_vault_or_an_explicit_user_scope() { + let root = std::env::temp_dir().join(format!( + "helixnotes-external-access-{}", + uuid::Uuid::new_v4() + )); + let vault = root.join("vault"); + let vault_file = vault.join("note.md"); + let outside_file = root.join("outside.txt"); + let outside_destination = root.join("export.txt"); + fs::create_dir_all(&vault).unwrap(); + fs::write(&vault_file, b"note").unwrap(); + fs::write(&outside_file, b"outside").unwrap(); + + let config = AppConfig { + active_vault: Some(vault.to_string_lossy().into_owned()), + ..Default::default() + }; + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_fs::init()) + .manage(AppState::new(config)) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + + assert!(ensure_readable_path(app.handle(), &vault_file).is_ok()); + assert!(ensure_readable_path(app.handle(), &outside_file).is_err()); + assert!(ensure_writable_path(app.handle(), &outside_destination).is_err()); + + app.fs_scope().allow_file(&outside_file).unwrap(); + app.fs_scope().allow_file(&outside_destination).unwrap(); + assert!(ensure_readable_path(app.handle(), &outside_file).is_ok()); + assert!(ensure_writable_path(app.handle(), &outside_destination).is_ok()); + + fs::remove_dir_all(root).unwrap(); } - Ok(()) } #[tauri::command] -pub fn open_file(path: String) -> Result<(), String> { - xdg_open(&path) +pub fn open_file(app: AppHandle, path: String) -> Result<(), String> { + ensure_readable_path(&app, Path::new(&path))?; + open_path_with_system(Path::new(&path)) +} + +#[tauri::command] +pub fn reveal_file(app: AppHandle, path: String) -> Result<(), String> { + ensure_readable_path(&app, Path::new(&path))?; + tauri_plugin_opener::reveal_item_in_dir(path).map_err(|error| error.to_string()) } #[tauri::command] pub fn open_url(url: String) -> Result<(), String> { - xdg_open(&url) + validate_external_url(&url)?; + open_url_with_system(&url) } #[tauri::command] -pub fn copy_file_to(source: String, destination: String) -> Result<(), String> { +pub fn copy_file_to(app: AppHandle, source: String, destination: String) -> Result<(), String> { + ensure_readable_path(&app, Path::new(&source))?; + ensure_writable_path(&app, Path::new(&destination))?; std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?; Ok(()) } #[tauri::command] -pub fn write_bytes_to(destination: String, data: Vec) -> Result<(), String> { +pub fn write_bytes_to(app: AppHandle, destination: String, data: Vec) -> Result<(), String> { + ensure_writable_path(&app, Path::new(&destination))?; std::fs::write(&destination, &data).map_err(|e| format!("Failed to write file: {}", e))?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4032e0..ccbc38c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod vault; use state::AppState; #[allow(unused_imports)] use tauri::{Emitter, Manager}; +use tauri_plugin_fs::FsExt; #[cfg(desktop)] use tauri::{ @@ -57,6 +58,16 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) + .on_webview_event(|webview, event| { + if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event { + let scope = webview.fs_scope(); + for path in paths { + if path.is_file() { + let _ = scope.allow_file(path); + } + } + } + }) .on_page_load(|webview, payload| { #[cfg(target_os = "macos")] if webview.label() == "main" @@ -127,6 +138,14 @@ pub fn run() { std::env::current_dir().unwrap_or_default().join(&path) }; if let Some(resolved_str) = resolved.to_str() { + if resolved.is_file() { + let _ = app.fs_scope().allow_file(&resolved); + if let Some(parent) = resolved.parent() { + let _ = app.fs_scope().allow_directory(parent, true); + } + let _ = + asset_scope::allow_external_note_assets(app.handle(), &resolved); + } let app_state = app.state::(); let _ = app_state.pending_open_file.lock().map(|mut p| { *p = Some(resolved_str.to_string()); @@ -205,6 +224,7 @@ pub fn run() { commands::trash_orphaned_attachments, commands::import_obsidian, commands::open_file, + commands::reveal_file, commands::open_url, commands::copy_file_to, commands::write_bytes_to, @@ -324,6 +344,13 @@ pub fn run() { std::path::Path::new(&cwd).join(path) }; if let Some(resolved_str) = resolved.to_str() { + if resolved.is_file() { + let _ = app.fs_scope().allow_file(&resolved); + if let Some(parent) = resolved.parent() { + let _ = app.fs_scope().allow_directory(parent, true); + } + let _ = asset_scope::allow_external_note_assets(app, &resolved); + } let _ = app.emit("open-file", resolved_str.to_string()); } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 7c12d34..cff32bf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -364,6 +364,10 @@ export async function openFile(path: string): Promise { return invoke("open_file", { path }); } +export async function revealFile(path: string): Promise { + return invoke("reveal_file", { path }); +} + export async function openUrl(url: string): Promise { return invoke("open_url", { url }); } diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index aa89f04..fef703a 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -37,12 +37,12 @@ reorderQuickAccess, moveNote, getAllTags, - createDailyNote + createDailyNote, + revealFile } from '$lib/api'; import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time'; import { openNoteWindow } from '$lib/utils/window'; import { encodeNoteDragPaths } from '$lib/utils/note-drag'; - import { revealItemInDir } from '@tauri-apps/plugin-opener'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; import TasksView from './TasksView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; @@ -1567,7 +1567,7 @@ Open in New Window {#if !isMobile} -