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));
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);
161 Some(path)
162 } else {
163 None
164 }
165 }
166
167 fn touch(&self, db: &Arc<RusqliteService>, item_id: &str, image_type: &str, tag: Option<&str>) {
170 let query = match tag {
171 Some(tag) => Query::with_params(
172 "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
173 WHERE item_id = ? AND image_type = ? AND image_tag = ?",
174 vec![
175 QueryParam::String(item_id.to_string()),
176 QueryParam::String(image_type.to_string()),
177 QueryParam::String(tag.to_string()),
178 ],
179 ),
180 None => Query::with_params(
181 "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
182 WHERE item_id = ? AND image_type = ?",
183 vec![
184 QueryParam::String(item_id.to_string()),
185 QueryParam::String(image_type.to_string()),
186 ],
187 ),
188 };
189 db.execute_detached(query);
193 }
194
195 #[allow(clippy::too_many_arguments)]
202 pub async fn save_thumbnail(
203 &self,
204 db: Arc<RusqliteService>,
205 item_id: &str,
206 image_type: &str,
207 tag: &str,
208 data: &[u8],
209 width: Option<i32>,
210 height: Option<i32>,
211 ) -> Result<PathBuf, String> {
212 if !self.is_enabled() {
213 return Err("Thumbnail caching is disabled".to_string());
214 }
215
216 let filename = format!(
220 "{}_{}_{}.jpg",
221 safe_component(item_id),
222 safe_component(image_type),
223 safe_component(tag)
224 );
225 let file_path = self.resolve_in_cache_dir(&filename)?;
226
227 self.ensure_space(db.clone(), data.len() as u64).await?;
229
230 std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?;
232
233 let query = Query::with_params(
235 "INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at)
236 VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
237 ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET
238 file_path = excluded.file_path,
239 width = excluded.width,
240 height = excluded.height,
241 file_size = excluded.file_size,
242 last_accessed = CURRENT_TIMESTAMP",
243 vec![
244 QueryParam::String(item_id.to_string()),
245 QueryParam::String(image_type.to_string()),
246 QueryParam::String(tag.to_string()),
247 QueryParam::String(file_path.to_string_lossy().to_string()),
248 width.map(QueryParam::Int).unwrap_or(QueryParam::Null),
249 height.map(QueryParam::Int).unwrap_or(QueryParam::Null),
250 QueryParam::Int64(data.len() as i64),
251 ],
252 );
253
254 db.execute(query)
255 .await
256 .map_err(|e| format!("Failed to save to database: {}", e))?;
257
258 Ok(file_path)
259 }
260
261 async fn ensure_space(
263 &self,
264 db: Arc<RusqliteService>,
265 needed_bytes: u64,
266 ) -> Result<(), String> {
267 let max_size = {
268 let config = self.config.lock().map_err(|e| e.to_string())?;
269 config.max_size_bytes
270 };
271
272 if max_size == 0 {
273 return Ok(()); }
275
276 let current_size = self.get_cache_size(db.clone()).await;
277
278 if current_size + needed_bytes <= max_size {
279 return Ok(()); }
281
282 let to_free = (current_size + needed_bytes).saturating_sub(max_size);
284 self.evict_lru(db, to_free).await
285 }
286
287 async fn evict_lru(&self, db: Arc<RusqliteService>, to_free: u64) -> Result<(), String> {
289 let mut freed: u64 = 0;
290
291 let query = Query::new(
293 "SELECT id, file_path, file_size FROM thumbnails
294 ORDER BY last_accessed ASC",
295 );
296
297 let items: Vec<(i64, String, i64)> = db
298 .query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
299 .await
300 .map_err(|e| e.to_string())?;
301
302 for (id, path, size) in items {
303 if freed >= to_free {
304 break;
305 }
306
307 let _ = std::fs::remove_file(&path);
309
310 let delete_query = Query::with_params(
312 "DELETE FROM thumbnails WHERE id = ?",
313 vec![QueryParam::Int64(id)],
314 );
315 let _ = db.execute(delete_query).await;
316
317 freed += size as u64;
318 }
319
320 Ok(())
321 }
322
323 pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64 {
325 let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails");
326
327 db.query_one(query, |row| row.get::<_, i64>(0))
328 .await
329 .unwrap_or(0) as u64
330 }
331
332 pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
334 let query = Query::new("SELECT COUNT(*) FROM thumbnails");
335
336 db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
337 }
338
339 pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64 {
341 let query = Query::with_params(
342 "SELECT value FROM cache_settings WHERE key = ?",
343 vec![QueryParam::String("image_cache_limit_bytes".to_string())],
344 );
345
346 db.query_optional(query, |row| row.get::<_, String>(0))
347 .await
348 .ok()
349 .flatten()
350 .and_then(|s| s.parse().ok())
351 .unwrap_or(1024 * 1024 * 1024) }
353
354 pub async fn set_limit(
356 &self,
357 db: Arc<RusqliteService>,
358 limit_bytes: u64,
359 ) -> Result<(), String> {
360 let query = Query::with_params(
362 "INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
363 VALUES (?, ?, CURRENT_TIMESTAMP)",
364 vec![
365 QueryParam::String("image_cache_limit_bytes".to_string()),
366 QueryParam::String(limit_bytes.to_string()),
367 ],
368 );
369
370 db.execute(query)
371 .await
372 .map_err(|e| format!("Failed to update setting: {}", e))?;
373
374 if let Ok(mut config) = self.config.lock() {
376 config.max_size_bytes = limit_bytes;
377 }
378
379 let current_size = self.get_cache_size(db.clone()).await;
381 if limit_bytes > 0 && current_size > limit_bytes {
382 let to_free = current_size - limit_bytes;
383 self.evict_lru(db, to_free).await?;
384 }
385
386 Ok(())
387 }
388
389 pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String> {
391 let query = Query::new("SELECT file_path FROM thumbnails");
393
394 let paths: Vec<String> = db
395 .query_many(query, |row| row.get(0))
396 .await
397 .map_err(|e| e.to_string())?;
398
399 for path in paths {
401 let _ = std::fs::remove_file(&path);
402 }
403
404 let delete_query = Query::new("DELETE FROM thumbnails");
406 db.execute(delete_query)
407 .await
408 .map_err(|e| format!("Failed to clear database: {}", e))?;
409
410 Ok(())
411 }
412
413 pub async fn delete_item(&self, db: Arc<RusqliteService>, item_id: &str) -> Result<(), String> {
415 let query = Query::with_params(
417 "SELECT file_path FROM thumbnails WHERE item_id = ?",
418 vec![QueryParam::String(item_id.to_string())],
419 );
420
421 let paths: Vec<String> = db
422 .query_many(query, |row| row.get(0))
423 .await
424 .map_err(|e| e.to_string())?;
425
426 for path in paths {
428 let _ = std::fs::remove_file(&path);
429 }
430
431 let delete_query = Query::with_params(
433 "DELETE FROM thumbnails WHERE item_id = ?",
434 vec![QueryParam::String(item_id.to_string())],
435 );
436 db.execute(delete_query)
437 .await
438 .map_err(|e| format!("Failed to delete from database: {}", e))?;
439
440 Ok(())
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::storage::db_service::RusqliteService;
448 use rusqlite::Connection;
449 use std::sync::{Arc, Mutex};
450 use tempfile::TempDir;
451
452 fn setup_test_db() -> (Arc<RusqliteService>, TempDir) {
453 let temp_dir = TempDir::new().unwrap();
454 let conn = Connection::open_in_memory().unwrap();
455
456 conn.execute(
458 "CREATE TABLE thumbnails (
459 id INTEGER PRIMARY KEY AUTOINCREMENT,
460 item_id TEXT NOT NULL,
461 image_type TEXT NOT NULL,
462 image_tag TEXT NOT NULL,
463 file_path TEXT NOT NULL,
464 width INTEGER,
465 height INTEGER,
466 file_size INTEGER DEFAULT 0,
467 cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
468 last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
469 UNIQUE(item_id, image_type, image_tag)
470 )",
471 [],
472 )
473 .unwrap();
474
475 conn.execute(
476 "CREATE TABLE cache_settings (
477 key TEXT PRIMARY KEY,
478 value TEXT NOT NULL,
479 updated_at TEXT DEFAULT CURRENT_TIMESTAMP
480 )",
481 [],
482 )
483 .unwrap();
484
485 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
486 (db_service, temp_dir)
487 }
488
489 #[test]
490 fn test_cache_creation() {
491 let temp_dir = TempDir::new().unwrap();
492 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
493
494 assert!(cache.cache_dir.exists());
495 assert!(cache.is_enabled());
496 }
497
498 #[tokio::test]
499 async fn test_save_and_get_thumbnail() {
500 let (conn, temp_dir) = setup_test_db();
501 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
502
503 let data = b"fake image data";
505 let path = cache
506 .save_thumbnail(
507 conn.clone(),
508 "item1",
509 "Primary",
510 "tag1",
511 data,
512 Some(100),
513 Some(100),
514 )
515 .await
516 .unwrap();
517
518 assert!(path.exists());
519
520 let cached = cache
522 .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
523 .await;
524 assert!(cached.is_some());
525 assert_eq!(cached.unwrap(), path);
526 }
527
528 #[tokio::test]
529 async fn test_cache_miss() {
530 let (conn, temp_dir) = setup_test_db();
531 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
532
533 let cached = cache
534 .get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
535 .await;
536 assert!(cached.is_none());
537 }
538
539 #[tokio::test]
540 async fn test_lru_eviction() {
541 let (conn, temp_dir) = setup_test_db();
542 let config = CacheConfig {
543 max_size_bytes: 100, ..Default::default()
545 };
546 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config);
547
548 let data = vec![0u8; 60];
550 cache
551 .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
552 .await
553 .unwrap();
554
555 cache
557 .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
558 .await
559 .unwrap();
560
561 let cached = cache
563 .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
564 .await;
565 assert!(cached.is_none());
566
567 let cached = cache
569 .get_cached_path(conn.clone(), "item2", "Primary", "tag2")
570 .await;
571 assert!(cached.is_some());
572 }
573
574 #[tokio::test]
575 async fn test_clear_cache() {
576 let (conn, temp_dir) = setup_test_db();
577 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
578
579 let data = b"fake image data";
580 cache
581 .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None)
582 .await
583 .unwrap();
584 cache
585 .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None)
586 .await
587 .unwrap();
588
589 assert_eq!(cache.get_item_count(conn.clone()).await, 2);
590
591 cache.clear_cache(conn.clone()).await.unwrap();
592
593 assert_eq!(cache.get_item_count(conn.clone()).await, 0);
594 assert_eq!(cache.get_cache_size(conn.clone()).await, 0);
595 }
596
597 #[tokio::test]
598 async fn test_set_limit() {
599 let (conn, temp_dir) = setup_test_db();
600 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
601
602 let data = vec![0u8; 50];
604 cache
605 .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
606 .await
607 .unwrap();
608 cache
609 .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
610 .await
611 .unwrap();
612
613 cache.set_limit(conn.clone(), 60).await.unwrap();
615
616 let size = cache.get_cache_size(conn.clone()).await;
618 assert!(size <= 60);
619 }
620
621 #[tokio::test]
627 async fn test_save_thumbnail_confines_traversal_item_id() {
628 let (conn, temp_dir) = setup_test_db();
629 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
630
631 let result = cache
632 .save_thumbnail(
633 conn.clone(),
634 "../evil",
635 "Primary",
636 "tag1",
637 b"fake image data",
638 None,
639 None,
640 )
641 .await;
642
643 let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
645 assert!(
646 !escaped.exists(),
647 "wrote outside the cache directory: {}",
648 escaped.display()
649 );
650
651 if let Ok(path) = result {
653 assert!(
654 path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
655 "returned a path outside the cache directory: {}",
656 path.display()
657 );
658 assert!(path.exists());
659 }
660 }
661
662 #[tokio::test]
667 async fn test_save_thumbnail_confines_absolute_item_id() {
668 let (conn, temp_dir) = setup_test_db();
669 let outside = TempDir::new().unwrap();
670 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
671
672 let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
673 let result = cache
674 .save_thumbnail(
675 conn.clone(),
676 &absolute_id,
677 "Primary",
678 "tag1",
679 b"fake image data",
680 None,
681 None,
682 )
683 .await;
684
685 let escaped = outside.path().join("evil_Primary_tag1.jpg");
686 assert!(
687 !escaped.exists(),
688 "wrote outside the cache directory: {}",
689 escaped.display()
690 );
691
692 if let Ok(path) = result {
693 assert!(
694 path.starts_with(&cache.cache_dir),
695 "returned a path outside the cache directory: {}",
696 path.display()
697 );
698 }
699 }
700
701 #[tokio::test]
705 async fn test_save_thumbnail_confines_traversal_image_type() {
706 let (conn, temp_dir) = setup_test_db();
707 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
708
709 let path = cache
710 .save_thumbnail(
711 conn.clone(),
712 "item1",
713 "../Primary",
714 "tag1",
715 b"fake image data",
716 None,
717 None,
718 )
719 .await
720 .expect("a malformed image_type should be sanitised, not break caching");
721
722 assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
725 assert!(path.exists());
726 }
727
728 #[tokio::test]
734 async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
735 let (conn, temp_dir) = setup_test_db();
736 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
737
738 let path = cache
739 .save_thumbnail(
740 conn.clone(),
741 "a1b2c3d4e5f60718293a4b5c6d7e8f90",
742 "Primary",
743 "abcdef0123456789",
744 b"fake image data",
745 None,
746 None,
747 )
748 .await
749 .unwrap();
750
751 assert_eq!(
752 path,
753 cache
754 .cache_dir
755 .join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
756 );
757 }
758
759 #[tokio::test]
765 async fn test_traversal_item_id_still_round_trips() {
766 let (conn, temp_dir) = setup_test_db();
767 let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
768
769 let saved = cache
770 .save_thumbnail(
771 conn.clone(),
772 "../evil",
773 "Primary",
774 "tag1",
775 b"fake image data",
776 None,
777 None,
778 )
779 .await
780 .unwrap();
781
782 let cached = cache
783 .get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
784 .await;
785 assert_eq!(cached, Some(saved));
786 }
787}