1use log::error;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::Mutex;
7
8use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
9
10#[derive(Debug, Clone)]
12pub struct CacheConfig {
13 pub max_size_bytes: u64,
15 pub cache_subdir: String,
17 pub enabled: bool,
19}
20
21impl Default for CacheConfig {
22 fn default() -> Self {
23 Self {
24 max_size_bytes: 1024 * 1024 * 1024, cache_subdir: "thumbnails".to_string(),
26 enabled: true,
27 }
28 }
29}
30
31fn safe_component(value: &str) -> String {
41 value.replace(|c: char| !c.is_alphanumeric(), "_")
42}
43
44pub struct ThumbnailCache {
46 config: Arc<Mutex<CacheConfig>>,
47 cache_dir: PathBuf,
48}
49
50impl ThumbnailCache {
51 pub fn new(app_data_dir: PathBuf, config: CacheConfig) -> Self {
53 let cache_dir = app_data_dir.join(&config.cache_subdir);
54
55 if let Err(e) = std::fs::create_dir_all(&cache_dir) {
57 error!("Failed to create thumbnail cache directory: {}", e);
58 }
59
60 Self {
61 config: Arc::new(Mutex::new(config)),
62 cache_dir,
63 }
64 }
65
66 fn resolve_in_cache_dir(&self, filename: &str) -> Result<PathBuf, String> {
76 let mut resolved = self.cache_dir.clone();
77 for part in Path::new(filename).components() {
78 match part {
79 std::path::Component::ParentDir => {
80 resolved.pop();
81 }
82 std::path::Component::CurDir => {}
83 other => resolved.push(other),
84 }
85 }
86
87 if resolved.starts_with(&self.cache_dir) {
88 Ok(resolved)
89 } else {
90 Err("Thumbnail path escapes the cache directory".to_string())
91 }
92 }
93
94 pub fn is_enabled(&self) -> bool {
96 self.config.lock().map(|c| c.enabled).unwrap_or(true)
97 }
98
99 pub async fn get_cached_path(
102 &self,
103 db: Arc<RusqliteService>,
104 item_id: &str,
105 image_type: &str,
106 tag: &str,
107 ) -> Option<PathBuf> {
108 let exact = Query::with_params(
110 "SELECT file_path FROM thumbnails
111 WHERE item_id = ? AND image_type = ? AND image_tag = ?",
112 vec![
113 QueryParam::String(item_id.to_string()),
114 QueryParam::String(image_type.to_string()),
115 QueryParam::String(tag.to_string()),
116 ],
117 );
118
119 if let Ok(Some(path_str)) = db
120 .query_optional(exact, |row| row.get::<_, String>(0))
121 .await
122 {
123 let path = PathBuf::from(&path_str);
124 if path.exists() {
125 self.touch(&db, item_id, image_type, Some(tag)).await;
126 return Some(path);
127 }
128 let _ = db
131 .execute(Query::with_params(
132 "DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
133 vec![
134 QueryParam::String(item_id.to_string()),
135 QueryParam::String(image_type.to_string()),
136 QueryParam::String(tag.to_string()),
137 ],
138 ))
139 .await;
140 }
141
142 let any_tag = Query::with_params(
148 "SELECT file_path FROM thumbnails
149 WHERE item_id = ? AND image_type = ?
150 ORDER BY cached_at DESC LIMIT 1",
151 vec![
152 QueryParam::String(item_id.to_string()),
153 QueryParam::String(image_type.to_string()),
154 ],
155 );
156
157 let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
158 let path = PathBuf::from(&path_str);
159 if path.exists() {
160 self.touch(&db, item_id, image_type, None).await;
161 Some(path)
162 } else {
163 None
164 }
165 }
166
167 async fn touch(
170 &self,
171 db: &Arc<RusqliteService>,
172 item_id: &str,
173 image_type: &str,
174 tag: Option<&str>,
175 ) {
176 let query = match tag {
177 Some(tag) => Query::with_params(
178 "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
179 WHERE item_id = ? AND image_type = ? AND image_tag = ?",
180 vec![
181 QueryParam::String(item_id.to_string()),
182 QueryParam::String(image_type.to_string()),
183 QueryParam::String(tag.to_string()),
184 ],
185 ),
186 None => Query::with_params(
187 "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
188 WHERE item_id = ? AND image_type = ?",
189 vec![
190 QueryParam::String(item_id.to_string()),
191 QueryParam::String(image_type.to_string()),
192 ],
193 ),
194 };
195 let _ = db.execute(query).await;
196 }
197
198 #[allow(clippy::too_many_arguments)]
205 pub async fn save_thumbnail(
206 &self,
207 db: Arc<RusqliteService>,
208 item_id: &str,
209 image_type: &str,
210 tag: &str,
211 data: &[u8],
212 width: Option<i32>,
213 height: Option<i32>,
214 ) -> Result<PathBuf, String> {
215 if !self.is_enabled() {
216 return Err("Thumbnail caching is disabled".to_string());
217 }
218
219 let filename = format!(
223 "{}_{}_{}.jpg",
224 safe_component(item_id),
225 safe_component(image_type),
226 safe_component(tag)
227 );
228 let file_path = self.resolve_in_cache_dir(&filename)?;
229
230 self.ensure_space(db.clone(), data.len() as u64).await?;
232
233 std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?;
235
236 let query = Query::with_params(
238 "INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at)
239 VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
240 ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET
241 file_path = excluded.file_path,
242 width = excluded.width,
243 height = excluded.height,
244 file_size = excluded.file_size,
245 last_accessed = CURRENT_TIMESTAMP",
246 vec![
247 QueryParam::String(item_id.to_string()),
248 QueryParam::String(image_type.to_string()),
249 QueryParam::String(tag.to_string()),
250 QueryParam::String(file_path.to_string_lossy().to_string()),
251 width.map(QueryParam::Int).unwrap_or(QueryParam::Null),
252 height.map(QueryParam::Int).unwrap_or(QueryParam::Null),
253 QueryParam::Int64(data.len() as i64),
254 ],
255 );
256
257 db.execute(query)
258 .await
259 .map_err(|e| format!("Failed to save to database: {}", e))?;
260
261 Ok(file_path)
262 }
263
264 async fn ensure_space(
266 &self,
267 db: Arc<RusqliteService>,
268 needed_bytes: u64,
269 ) -> Result<(), String> {
270 let max_size = {
271 let config = self.config.lock().map_err(|e| e.to_string())?;
272 config.max_size_bytes
273 };
274
275 if max_size == 0 {
276 return Ok(()); }
278
279 let current_size = self.get_cache_size(db.clone()).await;
280
281 if current_size + needed_bytes <= max_size {
282 return Ok(()); }
284
285 let to_free = (current_size + needed_bytes).saturating_sub(max_size);
287 self.evict_lru(db, to_free).await
288 }
289
290 async fn evict_lru(&self, db: Arc<RusqliteService>, to_free: u64) -> Result<(), String> {
292 let mut freed: u64 = 0;
293
294 let query = Query::new(
296 "SELECT id, file_path, file_size FROM thumbnails
297 ORDER BY last_accessed ASC",
298 );
299
300 let items: Vec<(i64, String, i64)> = db
301 .query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
302 .await
303 .map_err(|e| e.to_string())?;
304
305 for (id, path, size) in items {
306 if freed >= to_free {
307 break;
308 }
309
310 let _ = std::fs::remove_file(&path);
312
313 let delete_query = Query::with_params(
315 "DELETE FROM thumbnails WHERE id = ?",
316 vec![QueryParam::Int64(id)],
317 );
318 let _ = db.execute(delete_query).await;
319
320 freed += size as u64;
321 }
322
323 Ok(())
324 }
325
326 pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64 {
328 let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails");
329
330 db.query_one(query, |row| row.get::<_, i64>(0))
331 .await
332 .unwrap_or(0) as u64
333 }
334
335 pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
337 let query = Query::new("SELECT COUNT(*) FROM thumbnails");
338
339 db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
340 }
341
342 pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64 {
344 let query = Query::with_params(
345 "SELECT value FROM cache_settings WHERE key = ?",
346 vec![QueryParam::String("image_cache_limit_bytes".to_string())],
347 );
348
349 db.query_optional(query, |row| row.get::<_, String>(0))
350 .await
351 .ok()
352 .flatten()
353 .and_then(|s| s.parse().ok())
354 .unwrap_or(1024 * 1024 * 1024) }
356
357 pub async fn set_limit(
359 &self,
360 db: Arc<RusqliteService>,
361 limit_bytes: u64,
362 ) -> Result<(), String> {
363 let query = Query::with_params(
365 "INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
366 VALUES (?, ?, CURRENT_TIMESTAMP)",
367 vec![
368 QueryParam::String("image_cache_limit_bytes".to_string()),
369 QueryParam::String(limit_bytes.to_string()),
370 ],
371 );
372
373 db.execute(query)
374 .await
375 .map_err(|e| format!("Failed to update setting: {}", e))?;
376
377 if let Ok(mut config) = self.config.lock() {
379 config.max_size_bytes = limit_bytes;
380 }
381
382 let current_size = self.get_cache_size(db.clone()).await;
384 if limit_bytes > 0 && current_size > limit_bytes {
385 let to_free = current_size - limit_bytes;
386 self.evict_lru(db, to_free).await?;
387 }
388
389 Ok(())
390 }
391
392 pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String> {
394 let query = Query::new("SELECT file_path FROM thumbnails");
396
397 let paths: Vec<String> = db
398 .query_many(query, |row| row.get(0))
399 .await
400 .map_err(|e| e.to_string())?;
401
402 for path in paths {
404 let _ = std::fs::remove_file(&path);
405 }
406
407 let delete_query = Query::new("DELETE FROM thumbnails");
409 db.execute(delete_query)
410 .await
411 .map_err(|e| format!("Failed to clear database: {}", e))?;
412
413 Ok(())
414 }
415
416 pub async fn delete_item(&self, db: Arc<RusqliteService>, item_id: &str) -> Result<(), String> {
418 let query = Query::with_params(
420 "SELECT file_path FROM thumbnails WHERE item_id = ?",
421 vec![QueryParam::String(item_id.to_string())],
422 );
423
424 let paths: Vec<String> = db
425 .query_many(query, |row| row.get(0))
426 .await
427 .map_err(|e| e.to_string())?;
428
429 for path in paths {
431 let _ = std::fs::remove_file(&path);
432 }
433
434 let delete_query = Query::with_params(
436 "DELETE FROM thumbnails WHERE item_id = ?",
437 vec![QueryParam::String(item_id.to_string())],
438 );
439 db.execute(delete_query)
440 .await
441 .map_err(|e| format!("Failed to delete from database: {}", e))?;
442
443 Ok(())
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::storage::db_service::RusqliteService;
451 use rusqlite::Connection;
452 use std::sync::{Arc, Mutex};
453 use tempfile::TempDir;
454
455 fn setup_test_db() -> (Arc<RusqliteService>, TempDir) {
456 let temp_dir = TempDir::new().unwrap();
457 let conn = Connection::open_in_memory().unwrap();
458
459 conn.execute(
461 "CREATE TABLE thumbnails (
462 id INTEGER PRIMARY KEY AUTOINCREMENT,
463 item_id TEXT NOT NULL,
464 image_type TEXT NOT NULL,
465 image_tag TEXT NOT NULL,
466 file_path TEXT NOT NULL,
467 width INTEGER,
468 height INTEGER,
469 file_size INTEGER DEFAULT 0,
470 cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
471 last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
472 UNIQUE(item_id, image_type, image_tag)
473 )",
474 [],
475 )
476 .unwrap();
477
478 conn.execute(
479 "CREATE TABLE cache_settings (
480 key TEXT PRIMARY KEY,
481 value TEXT NOT NULL,
482 updated_at TEXT DEFAULT CURRENT_TIMESTAMP
483 )",
484 [],
485 )
486 .unwrap();
487
488 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
489 (db_service, temp_dir)
490 }
491
492 #[test]
493 fn test_cache_creation() {
494 let temp_dir = TempDir::new().unwrap();
495 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
496
497 assert!(cache.cache_dir.exists());
498 assert!(cache.is_enabled());
499 }
500
501 #[tokio::test]
502 async fn test_save_and_get_thumbnail() {
503 let (conn, temp_dir) = setup_test_db();
504 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
505
506 let data = b"fake image data";
508 let path = cache
509 .save_thumbnail(
510 conn.clone(),
511 "item1",
512 "Primary",
513 "tag1",
514 data,
515 Some(100),
516 Some(100),
517 )
518 .await
519 .unwrap();
520
521 assert!(path.exists());
522
523 let cached = cache
525 .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
526 .await;
527 assert!(cached.is_some());
528 assert_eq!(cached.unwrap(), path);
529 }
530
531 #[tokio::test]
532 async fn test_cache_miss() {
533 let (conn, temp_dir) = setup_test_db();
534 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
535
536 let cached = cache
537 .get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
538 .await;
539 assert!(cached.is_none());
540 }
541
542 #[tokio::test]
543 async fn test_lru_eviction() {
544 let (conn, temp_dir) = setup_test_db();
545 let config = CacheConfig {
546 max_size_bytes: 100, ..Default::default()
548 };
549 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config);
550
551 let data = vec![0u8; 60];
553 cache
554 .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
555 .await
556 .unwrap();
557
558 cache
560 .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
561 .await
562 .unwrap();
563
564 let cached = cache
566 .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
567 .await;
568 assert!(cached.is_none());
569
570 let cached = cache
572 .get_cached_path(conn.clone(), "item2", "Primary", "tag2")
573 .await;
574 assert!(cached.is_some());
575 }
576
577 #[tokio::test]
578 async fn test_clear_cache() {
579 let (conn, temp_dir) = setup_test_db();
580 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
581
582 let data = b"fake image data";
583 cache
584 .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None)
585 .await
586 .unwrap();
587 cache
588 .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None)
589 .await
590 .unwrap();
591
592 assert_eq!(cache.get_item_count(conn.clone()).await, 2);
593
594 cache.clear_cache(conn.clone()).await.unwrap();
595
596 assert_eq!(cache.get_item_count(conn.clone()).await, 0);
597 assert_eq!(cache.get_cache_size(conn.clone()).await, 0);
598 }
599
600 #[tokio::test]
601 async fn test_set_limit() {
602 let (conn, temp_dir) = setup_test_db();
603 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
604
605 let data = vec![0u8; 50];
607 cache
608 .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
609 .await
610 .unwrap();
611 cache
612 .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
613 .await
614 .unwrap();
615
616 cache.set_limit(conn.clone(), 60).await.unwrap();
618
619 let size = cache.get_cache_size(conn.clone()).await;
621 assert!(size <= 60);
622 }
623
624 #[tokio::test]
630 async fn test_save_thumbnail_confines_traversal_item_id() {
631 let (conn, temp_dir) = setup_test_db();
632 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
633
634 let result = cache
635 .save_thumbnail(
636 conn.clone(),
637 "../evil",
638 "Primary",
639 "tag1",
640 b"fake image data",
641 None,
642 None,
643 )
644 .await;
645
646 let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
648 assert!(
649 !escaped.exists(),
650 "wrote outside the cache directory: {}",
651 escaped.display()
652 );
653
654 if let Ok(path) = result {
656 assert!(
657 path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
658 "returned a path outside the cache directory: {}",
659 path.display()
660 );
661 assert!(path.exists());
662 }
663 }
664
665 #[tokio::test]
670 async fn test_save_thumbnail_confines_absolute_item_id() {
671 let (conn, temp_dir) = setup_test_db();
672 let outside = TempDir::new().unwrap();
673 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
674
675 let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
676 let result = cache
677 .save_thumbnail(
678 conn.clone(),
679 &absolute_id,
680 "Primary",
681 "tag1",
682 b"fake image data",
683 None,
684 None,
685 )
686 .await;
687
688 let escaped = outside.path().join("evil_Primary_tag1.jpg");
689 assert!(
690 !escaped.exists(),
691 "wrote outside the cache directory: {}",
692 escaped.display()
693 );
694
695 if let Ok(path) = result {
696 assert!(
697 path.starts_with(&cache.cache_dir),
698 "returned a path outside the cache directory: {}",
699 path.display()
700 );
701 }
702 }
703
704 #[tokio::test]
708 async fn test_save_thumbnail_confines_traversal_image_type() {
709 let (conn, temp_dir) = setup_test_db();
710 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
711
712 let path = cache
713 .save_thumbnail(
714 conn.clone(),
715 "item1",
716 "../Primary",
717 "tag1",
718 b"fake image data",
719 None,
720 None,
721 )
722 .await
723 .expect("a malformed image_type should be sanitised, not break caching");
724
725 assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
728 assert!(path.exists());
729 }
730
731 #[tokio::test]
737 async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
738 let (conn, temp_dir) = setup_test_db();
739 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
740
741 let path = cache
742 .save_thumbnail(
743 conn.clone(),
744 "a1b2c3d4e5f60718293a4b5c6d7e8f90",
745 "Primary",
746 "abcdef0123456789",
747 b"fake image data",
748 None,
749 None,
750 )
751 .await
752 .unwrap();
753
754 assert_eq!(
755 path,
756 cache
757 .cache_dir
758 .join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
759 );
760 }
761
762 #[tokio::test]
768 async fn test_traversal_item_id_still_round_trips() {
769 let (conn, temp_dir) = setup_test_db();
770 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
771
772 let saved = cache
773 .save_thumbnail(
774 conn.clone(),
775 "../evil",
776 "Primary",
777 "tag1",
778 b"fake image data",
779 None,
780 None,
781 )
782 .await
783 .unwrap();
784
785 let cached = cache
786 .get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
787 .await;
788 assert_eq!(cached, Some(saved));
789 }
790}