From f97e33732718ff305fd878f659ebfae064890763 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 22 Mar 2026 15:26:41 +0000 Subject: [PATCH] feat: image asset management (backend) --- Cargo.lock | 27 +++++++++++++++++++++++++++ crates/store/Cargo.toml | 1 + docs/tasks/image-handling.md | 10 +++++----- src-tauri/src/commands.rs | 2 ++ src-tauri/src/lib.rs | 3 +++ src-tauri/src/locations.rs | 10 ++++++++++ crates/core/src/lib.rs | 17 +++++++++++++++++ crates/store/src/lib.rs | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src-tauri/src/commands/images.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 file(s) changed, 419 insertion(s)(+), 5 deletion(s)(-) diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,12 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -437,6 +443,20 @@ "radium", "tap", "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", ] [[package]] @@ -971,6 +991,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" @@ -8607,6 +8633,7 @@ name = "writer-store" version = "0.2.0" dependencies = [ + "blake3", "chrono", "dirs", "log", diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -12,6 +12,7 @@ dirs = "6" tempfile = "3.27" log = "0.4" +blake3 = "1" writer-core = { path = "../core" } writer-md = { path = "../markdown" } diff --git a/docs/tasks/image-handling.md b/docs/tasks/image-handling.md --- a/docs/tasks/image-handling.md +++ b/docs/tasks/image-handling.md @@ -5,22 +5,22 @@ ## Backend (Tauri + Rust) -- [ ] Add `.writer-assets/` directory creation on location init +- [x] Add `.writer-assets/` directory creation on location init - Create directory if missing when a location is opened - Add to `.gitignore`-style ignore list for file watcher (don't index asset files as documents) -- [ ] Implement `image_import` command +- [x] Implement `image_import` command - Validate format (PNG, JPEG, GIF, WebP, SVG) and size (≤10 MB) - Hash file contents with blake3, derive filename - Copy to `.writer-assets/.` - Dedup: if hash exists, return existing path - Return relative asset path string -- [ ] Implement `image_delete` command +- [x] Implement `image_delete` command - Remove file from `.writer-assets/` - No dangling reference scan -- [ ] Implement `image_list` command +- [x] Implement `image_list` command - List all files in `.writer-assets/` - Return filename, size, extension -- [ ] Register commands in `lib.rs` and expose via Tauri +- [x] Register commands in `lib.rs` and expose via Tauri ## Frontend Ports & State diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -16,11 +16,13 @@ use writer_store::{SidebarTreeState, Store, StyleCheckSettings, UiLayoutSettings}; mod atproto; +mod images; mod md; mod standard_site; mod strings; pub use atproto::*; +pub use images::*; pub use md::*; pub use standard_site::*; pub use strings::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -148,6 +148,9 @@ cmd::global_capture_pause, cmd::global_capture_validate_shortcut, cmd::markdown_help_get, + cmd::image_import, + cmd::image_delete, + cmd::image_list, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/locations.rs b/src-tauri/src/locations.rs --- a/src-tauri/src/locations.rs +++ b/src-tauri/src/locations.rs @@ -256,6 +256,16 @@ None => continue, }; + // TODO: verify that effect of not indexing asset files is acceptable + if rel_path.starts_with(writer_core::ASSETS_DIR_NAME) { + log::debug!( + "Watcher skipping asset path: {:?} in location {:?}", + rel_path, + location_id + ); + continue; + } + if path.exists() && path.is_dir() { reconcile_directory_index_and_emit(app, store, location_id, rel_path, change_kind, None); continue; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,6 +9,23 @@ StyleScanInput, scan_style_matches, }; +/// Directory name for local image assets within a location root +pub const ASSETS_DIR_NAME: &str = ".writer-assets"; + +/// Supported image file extensions for import +pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg"]; + +/// Maximum allowed image file size (10 MiB) +pub const IMAGE_SIZE_LIMIT_BYTES: u64 = 10 * 1024 * 1024; + +/// Metadata for a locally-stored image asset +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ImageAsset { + pub filename: String, + pub size_bytes: u64, + pub extension: String, +} + /// Unique identifier for a document within a location /// Combines location_id + rel_path for stable identity #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -2237,6 +2237,154 @@ Ok(hits) } + + fn assets_dir(root_path: &Path) -> PathBuf { + root_path.join(writer_core::ASSETS_DIR_NAME) + } + + fn ensure_assets_dir(root_path: &Path) -> Result { + let dir = Self::assets_dir(root_path); + std::fs::create_dir_all(&dir) + .map_err(|e| AppError::io(format!("Failed to create assets directory {:?}: {}", dir, e)))?; + Ok(dir) + } + + /// Imports an image file into `.writer-assets/` under the given location. + /// + /// Validates format and size, hashes the file bytes with blake3, and copies + /// the file to `.writer-assets/.`. If the hash already exists the + /// existing path is returned without copying (dedup). + /// + /// Returns the relative asset path, e.g. `.writer-assets/abc123.png`. + pub fn image_import(&self, location_id: LocationId, source_path: &Path) -> Result { + let location = self + .location_get(location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; + + let ext = source_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidPath, "File has no extension"))?; + + if !writer_core::SUPPORTED_IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(AppError::new( + ErrorCode::InvalidPath, + format!( + "Unsupported image format: .{}. Allowed: {}", + ext, + writer_core::SUPPORTED_IMAGE_EXTENSIONS.join(", ") + ), + )); + } + + let metadata = std::fs::metadata(source_path) + .map_err(|e| AppError::io(format!("Failed to read source file metadata: {}", e)))?; + + if metadata.len() > writer_core::IMAGE_SIZE_LIMIT_BYTES { + return Err(AppError::new( + ErrorCode::InvalidPath, + format!( + "Image exceeds size limit ({} bytes > {} bytes)", + metadata.len(), + writer_core::IMAGE_SIZE_LIMIT_BYTES + ), + )); + } + + let bytes = + std::fs::read(source_path).map_err(|e| AppError::io(format!("Failed to read source file: {}", e)))?; + let hash = blake3::hash(&bytes); + let hash_hex = hash.to_hex(); + let dest_filename = format!("{}.{}", hash_hex, ext); + + let assets_dir = Self::ensure_assets_dir(&location.root_path)?; + let dest_path = assets_dir.join(&dest_filename); + + if dest_path.exists() { + let rel = format!("{}/{}", writer_core::ASSETS_DIR_NAME, dest_filename); + log::debug!("image_import dedup hit: {}", rel); + return Ok(rel); + } + + std::fs::copy(source_path, &dest_path) + .map_err(|e| AppError::io(format!("Failed to copy image to assets dir: {}", e)))?; + + let rel = format!("{}/{}", writer_core::ASSETS_DIR_NAME, dest_filename); + log::info!("image_import: stored {} as {}", source_path.display(), rel); + Ok(rel) + } + + /// Deletes an image asset from `.writer-assets/`. + /// + /// Returns `true` if the file was removed, `false` if it did not exist. + /// Returns an error if `asset_path` does not start with `.writer-assets/` + /// (path-traversal guard). + pub fn image_delete(&self, location_id: LocationId, asset_path: &str) -> Result { + let expected_prefix = format!("{}/", writer_core::ASSETS_DIR_NAME); + if !asset_path.starts_with(&expected_prefix) { + return Err(AppError::invalid_path(format!( + "asset_path must start with '{}'", + expected_prefix + ))); + } + + let location = self + .location_get(location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; + + let full_path = location.root_path.join(asset_path); + + if !full_path.exists() { + log::debug!("image_delete: file not found: {:?}", full_path); + return Ok(false); + } + + std::fs::remove_file(&full_path) + .map_err(|e| AppError::io(format!("Failed to delete asset {:?}: {}", full_path, e)))?; + + log::info!("image_delete: removed {:?}", full_path); + Ok(true) + } + + /// Lists all image assets in `.writer-assets/` for the given location. + /// + /// Returns an empty vec if the directory does not exist yet. + pub fn image_list(&self, location_id: LocationId) -> Result, AppError> { + let location = self + .location_get(location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; + + let assets_dir = Self::assets_dir(&location.root_path); + + if !assets_dir.exists() { + return Ok(Vec::new()); + } + + let entries = std::fs::read_dir(&assets_dir) + .map_err(|e| AppError::io(format!("Failed to read assets directory: {}", e)))?; + + let mut assets = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| AppError::io(format!("Failed to read assets entry: {}", e)))?; + let path = entry.path(); + + if !path.is_file() { + continue; + } + + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(); + + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + + let size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + + assets.push(writer_core::ImageAsset { filename, size_bytes, extension }); + } + + log::debug!("image_list: {} assets in location {:?}", assets.len(), location_id); + Ok(assets) + } } #[cfg(test)] @@ -2249,6 +2397,22 @@ let db_path = temp_dir.path().join("test.db"); let store = Store::open(&db_path).unwrap(); (store, temp_dir) + } + + fn create_test_location(store: &Store) -> (writer_core::LocationId, TempDir) { + let location_dir = TempDir::new().unwrap(); + let settings = UiLayoutSettings { create_readme_in_new_locations: false, ..UiLayoutSettings::default() }; + store.ui_layout_set(&settings).unwrap(); + let location = store + .location_add("Test".to_string(), location_dir.path().to_path_buf()) + .unwrap(); + (location.id, location_dir) + } + + fn write_test_image(dir: &std::path::Path, name: &str, bytes: &[u8]) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path } #[test] @@ -3173,5 +3337,119 @@ let loaded = store.ui_layout_get().unwrap(); assert!(!loaded.create_readme_in_new_locations); + } + + #[test] + fn test_image_import_png() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + let src = write_test_image(location_dir.path(), "photo.png", b"\x89PNG\r\n\x1a\nfake"); + let rel = store.image_import(location_id, &src).unwrap(); + + assert!(rel.starts_with(".writer-assets/")); + assert!(rel.ends_with(".png")); + + let full = location_dir.path().join(&rel); + assert!(full.exists(), "imported file should be on disk"); + } + + #[test] + fn test_image_import_dedup() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + let src = write_test_image(location_dir.path(), "same.jpg", b"fakejpeg"); + let rel1 = store.image_import(location_id, &src).unwrap(); + let rel2 = store.image_import(location_id, &src).unwrap(); + + assert_eq!(rel1, rel2, "same content should produce same path"); + + let assets_dir = location_dir.path().join(".writer-assets"); + let count = std::fs::read_dir(assets_dir).unwrap().count(); + assert_eq!(count, 1, "only one file should be on disk after dedup"); + } + + #[test] + fn test_image_import_invalid_format() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + let src = write_test_image(location_dir.path(), "program.exe", b"MZ"); + let result = store.image_import(location_id, &src); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); + } + + #[test] + fn test_image_import_size_limit() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + let oversized: Vec = vec![0u8; (writer_core::IMAGE_SIZE_LIMIT_BYTES + 1) as usize]; + let src = write_test_image(location_dir.path(), "big.png", &oversized); + let result = store.image_import(location_id, &src); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); + } + + #[test] + fn test_image_delete_existing() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + + let src = write_test_image(location_dir.path(), "delete_me.gif", b"GIF89a"); + let rel = store.image_import(location_id, &src).unwrap(); + + let removed = store.image_delete(location_id, &rel).unwrap(); + assert!(removed, "should return true when file was deleted"); + + let full = location_dir.path().join(&rel); + assert!(!full.exists(), "file should no longer be on disk"); + } + + #[test] + fn test_image_delete_nonexistent() { + let (store, _tmp) = create_test_store(); + let (location_id, _location_dir) = create_test_location(&store); + + let removed = store + .image_delete(location_id, ".writer-assets/doesnotexist.png") + .unwrap(); + assert!(!removed, "should return false when file was not found"); + } + + #[test] + fn test_image_delete_traversal_guard() { + let (store, _tmp) = create_test_store(); + let (location_id, _location_dir) = create_test_location(&store); + let result = store.image_delete(location_id, "../etc/passwd"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); + } + + #[test] + fn test_image_list_empty() { + let (store, _tmp) = create_test_store(); + let (location_id, _location_dir) = create_test_location(&store); + + let assets = store.image_list(location_id).unwrap(); + assert!(assets.is_empty(), "no assets dir yet → empty list"); + } + + #[test] + fn test_image_list_multiple() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + + let s1 = write_test_image(location_dir.path(), "a.png", b"\x89PNG one"); + let s2 = write_test_image(location_dir.path(), "b.webp", b"RIFF webp"); + store.image_import(location_id, &s1).unwrap(); + store.image_import(location_id, &s2).unwrap(); + + let assets = store.image_list(location_id).unwrap(); + assert_eq!(assets.len(), 2, "should list both imported images"); + + let exts: Vec<&str> = assets.iter().map(|a| a.extension.as_str()).collect(); + assert!(exts.contains(&"png"), "png should be present"); + assert!(exts.contains(&"webp"), "webp should be present"); } } diff --git a/src-tauri/src/commands/images.rs b/src-tauri/src/commands/images.rs new file mode 100644 --- /dev/null +++ b/src-tauri/src/commands/images.rs @@ -0,0 +1,76 @@ +use super::{AppState, CommandResponse}; +use std::path::PathBuf; +use tauri::State; +use writer_core::{AppError, CommandResult, ImageAsset, LocationId}; + +/// Imports an image file into `.writer-assets/` for the given location. +/// +/// Validates format (PNG, JPEG, GIF, WebP, SVG) and size (≤ 10 MiB), +/// hashes the file with blake3, and copies it to `.writer-assets/.`. +/// If the hash already exists the call is a no-op and returns the existing path. +/// +/// Returns the relative asset path, e.g. `.writer-assets/abc123.png`. +#[tauri::command] +pub fn image_import(state: State<'_, AppState>, location_id: i64, source_path: String) -> CommandResponse { + let location_id = LocationId(location_id); + let source = PathBuf::from(&source_path); + + log::debug!("image_import: location={:?}, source={:?}", location_id, source); + + match state.store.image_import(location_id, &source) { + Ok(rel_path) => { + log::info!("image_import: ok → {}", rel_path); + Ok(CommandResult::ok(rel_path)) + } + Err(e) => { + log::error!("image_import failed: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +/// Deletes an image asset from `.writer-assets/` for the given location. +/// +/// Returns `true` if the file was removed, `false` if it did not exist. +/// Returns an error if `asset_path` does not start with `.writer-assets/`. +#[tauri::command] +pub fn image_delete(state: State<'_, AppState>, location_id: i64, asset_path: String) -> CommandResponse { + let location_id = LocationId(location_id); + + log::debug!("image_delete: location={:?}, asset_path={}", location_id, asset_path); + + match state.store.image_delete(location_id, &asset_path) { + Ok(removed) => { + log::info!("image_delete: removed={}", removed); + Ok(CommandResult::ok(removed)) + } + Err(e) => { + log::error!("image_delete failed: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +/// Lists all image assets in `.writer-assets/` for the given location. +/// +/// Returns an empty vec if the assets directory does not exist yet. +#[tauri::command] +pub fn image_list(state: State<'_, AppState>, location_id: i64) -> CommandResponse> { + let location_id = LocationId(location_id); + + log::debug!("image_list: location={:?}", location_id); + + match state.store.image_list(location_id) { + Ok(assets) => { + log::debug!("image_list: {} assets", assets.len()); + Ok(CommandResult::ok(assets)) + } + Err(e) => { + log::error!("image_list failed: {}", e); + Ok(CommandResult::err(AppError::io(format!( + "Failed to list images: {}", + e + )))) + } + } +} -- tangled.sh