asdf98 commited on
Commit
d89e122
·
verified ·
1 Parent(s): 8a4316c

fix: improve library import reliability, supported raster formats, duplicate detection, and browser-safe normalization

Browse files
Files changed (1) hide show
  1. src-tauri/src/library.rs +63 -32
src-tauri/src/library.rs CHANGED
@@ -1,6 +1,7 @@
1
  use base64::{engine::general_purpose, Engine as _};
2
- use image::GenericImageView;
3
  use serde::{Deserialize, Serialize};
 
4
  use std::sync::Mutex;
5
  use tauri::{AppHandle, Manager};
6
  use uuid::Uuid;
@@ -46,6 +47,7 @@ pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<St
46
  let client = reqwest::Client::builder()
47
  .timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
48
  .redirect(reqwest::redirect::Policy::limited(5))
 
49
  .build()
50
  .map_err(|e| format!("HTTP client error: {e}"))?;
51
 
@@ -68,15 +70,16 @@ pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<St
68
  #[tauri::command]
69
  pub fn library_import_local(app: AppHandle, path: String, title: Option<String>) -> Result<LibraryItem, String> {
70
  if path.trim().is_empty() { return Err("Path cannot be empty".to_string()); }
71
- if path.contains("..") { return Err("Path traversal is not allowed".to_string()); }
72
- let metadata = std::fs::metadata(&path).map_err(|e| format!("Cannot access file: {e}"))?;
 
73
  if !metadata.is_file() { return Err("Path is not a file".to_string()); }
74
  if metadata.len() > MAX_IMAGE_SIZE as u64 {
75
  return Err(format!("Image file is too large ({:.1} MB). Maximum allowed is {:.0} MB", metadata.len() as f64 / (1024.0 * 1024.0), MAX_IMAGE_SIZE as f64 / (1024.0 * 1024.0)));
76
  }
77
- let bytes = std::fs::read(&path).map_err(|e| format!("Cannot read file: {e}"))?;
78
- let fallback_title = std::path::Path::new(&path).file_stem().and_then(|s| s.to_str()).map(|s| s.to_string());
79
- import_bytes(&app, &bytes, &format!("file://{path}"), None, title.or(fallback_title))
80
  }
81
 
82
  #[tauri::command]
@@ -100,11 +103,50 @@ fn decode_data_url_bytes(data_url: &str) -> Result<Vec<u8>, String> {
100
  fn clean_title(title: Option<String>) -> String {
101
  let t = title.unwrap_or_else(|| "Untitled reference".to_string()).trim().to_string();
102
  let t = if t.is_empty() { "Untitled reference".to_string() } else { t };
103
- t.chars().take(MAX_TITLE_LEN).collect()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  }
105
 
106
  fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
107
  if bytes.is_empty() { return Err("Image data is empty".to_string()); }
 
108
  let hash = blake3::hash(bytes).to_hex().to_string();
109
  {
110
  let state = app.state::<LibraryState>();
@@ -112,20 +154,20 @@ fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<Str
112
  if let Some(existing) = items.iter().find(|i| i.hash == hash) { return Ok(existing.clone()); }
113
  if items.len() >= MAX_LIBRARY_ITEMS { return Err(format!("Library is full ({MAX_LIBRARY_ITEMS} items). Please remove some items before adding new ones.")); }
114
  }
115
- let img = image::load_from_memory(bytes).map_err(|e| format!("Unsupported image format: {e}"))?;
116
  let (width, height) = img.dimensions();
117
  if width == 0 || height == 0 { return Err("Image has invalid dimensions".to_string()); }
118
  if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
119
  return Err(format!("Image dimensions too large ({width}x{height}). Maximum is {MAX_IMAGE_DIMENSION}px in either dimension."));
120
  }
121
  let colors = extract_palette(&img, 6);
122
- let mime = detect_mime(bytes, url);
123
  let item = LibraryItem {
124
  id: Uuid::new_v4().to_string(),
125
  url: url.to_string(),
126
  source_url: source_url.unwrap_or_default(),
127
  title: clean_title(title),
128
- data_url: format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(bytes)),
129
  hash,
130
  width,
131
  height,
@@ -136,27 +178,28 @@ fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<Str
136
  let state = app.state::<LibraryState>();
137
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
138
  items.push(item.clone());
 
139
  crate::persistence::save_json(app, "library.json", &*items)?;
140
  Ok(item)
141
  }
142
 
143
  #[tauri::command]
144
  pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>, tags: Option<Vec<String>>) -> Result<LibraryItem, String> {
 
145
  let state = app.state::<LibraryState>();
146
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
147
  let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
148
  if let Some(t) = title {
149
  let t = t.trim();
150
- if !t.is_empty() { item.title = t.chars().take(MAX_TITLE_LEN).collect(); }
151
  }
152
  if let Some(t) = tags {
153
  if t.len() > 50 { return Err("Too many tags (maximum 50)".to_string()); }
154
  let mut clean = Vec::new();
155
  for tag in t {
156
- let tag = tag.trim();
157
  if tag.is_empty() { continue; }
158
  if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
159
- let tag = tag.to_string();
160
  if !clean.contains(&tag) { clean.push(tag); }
161
  }
162
  item.tags = clean;
@@ -168,6 +211,7 @@ pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>
168
 
169
  #[tauri::command]
170
  pub fn library_remove_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
 
171
  let state = app.state::<LibraryState>();
172
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
173
  let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
@@ -182,7 +226,8 @@ pub fn library_load(app: AppHandle) -> Result<Vec<LibraryItem>, String> {
182
  let loaded: Vec<LibraryItem> = crate::persistence::load_json(&app, "library.json")?;
183
  let state = app.state::<LibraryState>();
184
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
185
- if items.is_empty() && !loaded.is_empty() { *items = loaded; }
 
186
  Ok(items.clone())
187
  }
188
 
@@ -199,6 +244,7 @@ pub fn library_search(app: AppHandle, query: String) -> Result<Vec<LibraryItem>,
199
 
200
  #[tauri::command]
201
  pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
 
202
  let state = app.state::<LibraryState>();
203
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
204
  let before = items.len();
@@ -209,35 +255,20 @@ pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
209
 
210
  #[tauri::command]
211
  pub fn library_add_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
212
- let tag = tag.trim().to_string();
 
213
  if tag.is_empty() { return Err("Tag cannot be empty".to_string()); }
214
  if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
215
  let state = app.state::<LibraryState>();
216
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
217
  let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
218
  if item.tags.len() >= 50 && !item.tags.contains(&tag) { return Err("Too many tags (maximum 50 per item)".to_string()); }
219
- if !item.tags.contains(&tag) { item.tags.push(tag); }
220
  let result = item.clone();
221
  crate::persistence::save_json(&app, "library.json", &*items)?;
222
  Ok(result)
223
  }
224
 
225
- fn detect_mime(bytes: &[u8], url: &str) -> &'static str {
226
- if bytes.len() >= 12 {
227
- if bytes[0..4] == [0x89,0x50,0x4E,0x47] { return "image/png"; }
228
- if bytes[0..3] == [0xFF,0xD8,0xFF] { return "image/jpeg"; }
229
- if bytes[0..4] == [0x47,0x49,0x46,0x38] { return "image/gif"; }
230
- if bytes[0..4] == [0x52,0x49,0x46,0x46] && bytes[8..12] == [0x57,0x45,0x42,0x50] { return "image/webp"; }
231
- if bytes[0..2] == [0x42,0x4D] { return "image/bmp"; }
232
- }
233
- let lower = url.to_lowercase();
234
- if lower.ends_with(".png") { "image/png" }
235
- else if lower.ends_with(".webp") { "image/webp" }
236
- else if lower.ends_with(".gif") { "image/gif" }
237
- else if lower.ends_with(".bmp") { "image/bmp" }
238
- else { "image/jpeg" }
239
- }
240
-
241
  fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
242
  let thumb = img.thumbnail(120,120).to_rgb8();
243
  let mut pixels: Vec<[u8;3]> = thumb.pixels().filter_map(|p| { let [r,g,b]=[p[0],p[1],p[2]]; if r.max(g).max(b) < 12 { None } else { Some([r,g,b]) } }).collect();
 
1
  use base64::{engine::general_purpose, Engine as _};
2
+ use image::{GenericImageView, ImageFormat};
3
  use serde::{Deserialize, Serialize};
4
+ use std::io::Cursor;
5
  use std::sync::Mutex;
6
  use tauri::{AppHandle, Manager};
7
  use uuid::Uuid;
 
47
  let client = reqwest::Client::builder()
48
  .timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
49
  .redirect(reqwest::redirect::Policy::limited(5))
50
+ .user_agent("LumaRef/1.0 (+https://huggingface.co/asdf98/musealpha)")
51
  .build()
52
  .map_err(|e| format!("HTTP client error: {e}"))?;
53
 
 
70
  #[tauri::command]
71
  pub fn library_import_local(app: AppHandle, path: String, title: Option<String>) -> Result<LibraryItem, String> {
72
  if path.trim().is_empty() { return Err("Path cannot be empty".to_string()); }
73
+ let path_buf = std::path::PathBuf::from(&path);
74
+ let canonical = path_buf.canonicalize().map_err(|e| format!("Cannot access file: {e}"))?;
75
+ let metadata = std::fs::metadata(&canonical).map_err(|e| format!("Cannot access file: {e}"))?;
76
  if !metadata.is_file() { return Err("Path is not a file".to_string()); }
77
  if metadata.len() > MAX_IMAGE_SIZE as u64 {
78
  return Err(format!("Image file is too large ({:.1} MB). Maximum allowed is {:.0} MB", metadata.len() as f64 / (1024.0 * 1024.0), MAX_IMAGE_SIZE as f64 / (1024.0 * 1024.0)));
79
  }
80
+ let bytes = std::fs::read(&canonical).map_err(|e| format!("Cannot read file: {e}"))?;
81
+ let fallback_title = canonical.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string());
82
+ import_bytes(&app, &bytes, &format!("file://{}", canonical.to_string_lossy()), None, title.or(fallback_title))
83
  }
84
 
85
  #[tauri::command]
 
103
  fn clean_title(title: Option<String>) -> String {
104
  let t = title.unwrap_or_else(|| "Untitled reference".to_string()).trim().to_string();
105
  let t = if t.is_empty() { "Untitled reference".to_string() } else { t };
106
+ t.chars().filter(|c| !c.is_control()).take(MAX_TITLE_LEN).collect()
107
+ }
108
+
109
+ fn ensure_library_loaded(app: &AppHandle) -> Result<(), String> {
110
+ let loaded: Vec<LibraryItem> = crate::persistence::load_json(app, "library.json").unwrap_or_default();
111
+ let state = app.state::<LibraryState>();
112
+ let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
113
+ if items.is_empty() && !loaded.is_empty() { *items = loaded; }
114
+ Ok(())
115
+ }
116
+
117
+ fn browser_safe_data(bytes: &[u8], url: &str, img: &image::DynamicImage) -> Result<(Vec<u8>, &'static str), String> {
118
+ let guessed = image::guess_format(bytes).ok();
119
+ match guessed {
120
+ Some(ImageFormat::Png) => Ok((bytes.to_vec(), "image/png")),
121
+ Some(ImageFormat::Jpeg) => Ok((bytes.to_vec(), "image/jpeg")),
122
+ Some(ImageFormat::Gif) => Ok((bytes.to_vec(), "image/gif")),
123
+ Some(ImageFormat::WebP) => Ok((bytes.to_vec(), "image/webp")),
124
+ Some(ImageFormat::Bmp) => Ok((bytes.to_vec(), "image/bmp")),
125
+ Some(ImageFormat::Ico) => Ok((bytes.to_vec(), "image/x-icon")),
126
+ Some(ImageFormat::Tiff) => {
127
+ let mut out = Vec::new();
128
+ img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| format!("PNG conversion failed: {e}"))?;
129
+ Ok((out, "image/png"))
130
+ }
131
+ _ => {
132
+ let lower = url.to_lowercase();
133
+ if lower.ends_with(".png") { Ok((bytes.to_vec(), "image/png")) }
134
+ else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { Ok((bytes.to_vec(), "image/jpeg")) }
135
+ else if lower.ends_with(".gif") { Ok((bytes.to_vec(), "image/gif")) }
136
+ else if lower.ends_with(".webp") { Ok((bytes.to_vec(), "image/webp")) }
137
+ else if lower.ends_with(".bmp") { Ok((bytes.to_vec(), "image/bmp")) }
138
+ else {
139
+ let mut out = Vec::new();
140
+ img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| format!("PNG conversion failed: {e}"))?;
141
+ Ok((out, "image/png"))
142
+ }
143
+ }
144
+ }
145
  }
146
 
147
  fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
148
  if bytes.is_empty() { return Err("Image data is empty".to_string()); }
149
+ ensure_library_loaded(app)?;
150
  let hash = blake3::hash(bytes).to_hex().to_string();
151
  {
152
  let state = app.state::<LibraryState>();
 
154
  if let Some(existing) = items.iter().find(|i| i.hash == hash) { return Ok(existing.clone()); }
155
  if items.len() >= MAX_LIBRARY_ITEMS { return Err(format!("Library is full ({MAX_LIBRARY_ITEMS} items). Please remove some items before adding new ones.")); }
156
  }
157
+ let img = image::load_from_memory(bytes).map_err(|e| format!("Unsupported raster image format: {e}. Supported: PNG, JPEG, WebP, GIF, BMP, ICO, TIFF."))?;
158
  let (width, height) = img.dimensions();
159
  if width == 0 || height == 0 { return Err("Image has invalid dimensions".to_string()); }
160
  if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
161
  return Err(format!("Image dimensions too large ({width}x{height}). Maximum is {MAX_IMAGE_DIMENSION}px in either dimension."));
162
  }
163
  let colors = extract_palette(&img, 6);
164
+ let (stored_bytes, mime) = browser_safe_data(bytes, url, &img)?;
165
  let item = LibraryItem {
166
  id: Uuid::new_v4().to_string(),
167
  url: url.to_string(),
168
  source_url: source_url.unwrap_or_default(),
169
  title: clean_title(title),
170
+ data_url: format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(stored_bytes)),
171
  hash,
172
  width,
173
  height,
 
178
  let state = app.state::<LibraryState>();
179
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
180
  items.push(item.clone());
181
+ items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
182
  crate::persistence::save_json(app, "library.json", &*items)?;
183
  Ok(item)
184
  }
185
 
186
  #[tauri::command]
187
  pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>, tags: Option<Vec<String>>) -> Result<LibraryItem, String> {
188
+ ensure_library_loaded(&app)?;
189
  let state = app.state::<LibraryState>();
190
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
191
  let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
192
  if let Some(t) = title {
193
  let t = t.trim();
194
+ if !t.is_empty() { item.title = t.chars().filter(|c| !c.is_control()).take(MAX_TITLE_LEN).collect(); }
195
  }
196
  if let Some(t) = tags {
197
  if t.len() > 50 { return Err("Too many tags (maximum 50)".to_string()); }
198
  let mut clean = Vec::new();
199
  for tag in t {
200
+ let tag = tag.trim().to_lowercase();
201
  if tag.is_empty() { continue; }
202
  if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
 
203
  if !clean.contains(&tag) { clean.push(tag); }
204
  }
205
  item.tags = clean;
 
211
 
212
  #[tauri::command]
213
  pub fn library_remove_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
214
+ ensure_library_loaded(&app)?;
215
  let state = app.state::<LibraryState>();
216
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
217
  let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
 
226
  let loaded: Vec<LibraryItem> = crate::persistence::load_json(&app, "library.json")?;
227
  let state = app.state::<LibraryState>();
228
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
229
+ if items.is_empty() || loaded.len() != items.len() { *items = loaded; }
230
+ items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
231
  Ok(items.clone())
232
  }
233
 
 
244
 
245
  #[tauri::command]
246
  pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
247
+ ensure_library_loaded(&app)?;
248
  let state = app.state::<LibraryState>();
249
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
250
  let before = items.len();
 
255
 
256
  #[tauri::command]
257
  pub fn library_add_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
258
+ ensure_library_loaded(&app)?;
259
+ let tag = tag.trim().to_lowercase();
260
  if tag.is_empty() { return Err("Tag cannot be empty".to_string()); }
261
  if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
262
  let state = app.state::<LibraryState>();
263
  let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
264
  let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
265
  if item.tags.len() >= 50 && !item.tags.contains(&tag) { return Err("Too many tags (maximum 50 per item)".to_string()); }
266
+ if !item.tags.contains(&tag) { item.tags.push(tag); item.tags.sort(); }
267
  let result = item.clone();
268
  crate::persistence::save_json(&app, "library.json", &*items)?;
269
  Ok(result)
270
  }
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
273
  let thumb = img.thumbnail(120,120).to_rgb8();
274
  let mut pixels: Vec<[u8;3]> = thumb.pixels().filter_map(|p| { let [r,g,b]=[p[0],p[1],p[2]]; if r.max(g).max(b) < 12 { None } else { Some([r,g,b]) } }).collect();