Skip to main content

trailcache_core/cache/
manager.rs

1use std::path::PathBuf;
2
3use anyhow::{anyhow, Context, Result};
4use argon2::Argon2;
5use chacha20poly1305::{
6    aead::{Aead, KeyInit},
7    ChaCha20Poly1305, Nonce,
8};
9use chrono::{DateTime, Utc};
10use rand::RngCore;
11use serde::{de::DeserializeOwned, Deserialize, Serialize};
12use tracing::debug;
13
14use crate::models::{
15    Adult, AdvancementDashboard, Award, Commissioner, Event, Key3Leaders, LeadershipPosition,
16    MeritBadgeProgress, MeritBadgeRequirement, OrgProfile, Parent, Patrol, RankProgress,
17    ReadyToAward, UnitInfo, Youth,
18};
19
20/// Badge requirements with optional version string, as stored in cache.
21pub type BadgeRequirementsData = (Vec<MeritBadgeRequirement>, Option<String>);
22
23// Encryption constants
24const NONCE_SIZE: usize = 12; // ChaCha20-Poly1305 nonce size
25
26/// Derive a 256-bit encryption key from password and salt using Argon2.
27/// The same password + salt always produces the same key.
28fn derive_key_from_password(password: &str, salt: &str) -> Result<[u8; 32]> {
29    let mut key = [0u8; 32];
30    // Use Argon2id with default parameters - secure and reasonably fast
31    Argon2::default()
32        .hash_password_into(password.as_bytes(), salt.as_bytes(), &mut key)
33        .map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
34    Ok(key)
35}
36
37/// Encrypts data using ChaCha20-Poly1305 with a random nonce.
38/// Returns: [12-byte nonce][ciphertext with auth tag]
39fn encrypt_data(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
40    let cipher = ChaCha20Poly1305::new(key.into());
41
42    // Generate random nonce
43    let mut nonce_bytes = [0u8; NONCE_SIZE];
44    rand::thread_rng().fill_bytes(&mut nonce_bytes);
45    let nonce = Nonce::from_slice(&nonce_bytes);
46
47    // Encrypt
48    let ciphertext = cipher
49        .encrypt(nonce, plaintext)
50        .map_err(|e| anyhow!("Encryption failed: {}", e))?;
51
52    // Prepend nonce to ciphertext
53    let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
54    result.extend_from_slice(&nonce_bytes);
55    result.extend_from_slice(&ciphertext);
56
57    Ok(result)
58}
59
60/// Decrypts data encrypted by encrypt_data.
61/// Expects: [12-byte nonce][ciphertext with auth tag]
62fn decrypt_data(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
63    if data.len() < NONCE_SIZE {
64        return Err(anyhow!(
65            "Encrypted data too short: expected at least {} bytes, got {}",
66            NONCE_SIZE,
67            data.len()
68        ));
69    }
70
71    let cipher = ChaCha20Poly1305::new(key.into());
72
73    // Extract nonce and ciphertext
74    let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);
75    let ciphertext = &data[NONCE_SIZE..];
76
77    // Decrypt
78    let plaintext = cipher
79        .decrypt(nonce, ciphertext)
80        .map_err(|e| anyhow!("Decryption failed: {}", e))?;
81
82    Ok(plaintext)
83}
84
85/// Consider cache stale after 1 hour.
86/// Balances freshness with reducing unnecessary API calls for slowly-changing data.
87const CACHE_STALE_MINUTES: i64 = 60;
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct CachedData<T> {
91    pub data: T,
92    pub cached_at: DateTime<Utc>,
93}
94
95impl<T> CachedData<T> {
96    pub fn new(data: T) -> Self {
97        Self {
98            data,
99            cached_at: Utc::now(),
100        }
101    }
102
103    pub fn age_minutes(&self) -> i64 {
104        let now = Utc::now();
105        (now - self.cached_at).num_minutes()
106    }
107
108    pub fn age_display(&self) -> String {
109        let minutes = self.age_minutes();
110        if minutes < 0 {
111            // Handle clock skew gracefully
112            "just now".to_string()
113        } else if minutes < 1 {
114            "just now".to_string()
115        } else if minutes < 60 {
116            format!("{}m ago", minutes)
117        } else if minutes < 1440 {
118            let hours = minutes / 60;
119            let remaining_mins = minutes % 60;
120            if remaining_mins >= 30 {
121                // Round up: 1h 30m+ becomes 2h
122                format!("{}h ago", hours + 1)
123            } else {
124                format!("{}h ago", hours)
125            }
126        } else {
127            let days = minutes / 1440;
128            let remaining_hours = (minutes % 1440) / 60;
129            if remaining_hours >= 12 {
130                // Round up: 1d 12h+ becomes 2d
131                format!("{}d ago", days + 1)
132            } else {
133                format!("{}d ago", days)
134            }
135        }
136    }
137
138    pub fn is_stale(&self) -> bool {
139        self.age_minutes() > CACHE_STALE_MINUTES
140    }
141}
142
143#[derive(Clone)]
144pub struct CacheManager {
145    cache_dir: PathBuf,
146    encryption_key: [u8; 32],
147}
148
149impl CacheManager {
150    /// Create a CacheManager for pre-login state.
151    /// Uses a placeholder key that will fail to decrypt any existing cache,
152    /// causing all loads to return `Ok(None)` until `set_password()` is called.
153    /// Saves made before `set_password()` will be encrypted with the wrong key
154    /// and become unreadable — this is intentional (no data should be cached pre-login).
155    pub fn new_without_encryption(cache_dir: PathBuf) -> Result<Self> {
156        std::fs::create_dir_all(&cache_dir)?;
157        Ok(Self {
158            cache_dir,
159            encryption_key: [0u8; 32],
160        })
161    }
162
163    /// Set encryption key derived from password + org_guid.
164    /// Must be called after login before cache operations will work.
165    pub fn set_password(&mut self, password: &str, org_guid: &str) {
166        use tracing::{info, warn};
167        match derive_key_from_password(password, org_guid) {
168            Ok(key) => {
169                self.encryption_key = key;
170                info!("Encryption key derived from password");
171            }
172            Err(e) => {
173                warn!("Key derivation failed, cache encryption unavailable: {}", e);
174            }
175        }
176    }
177
178    fn cache_path(&self, name: &str) -> PathBuf {
179        self.cache_dir.join(format!("{}.enc", name))
180    }
181
182    fn load<T: DeserializeOwned>(&self, name: &str) -> Result<Option<CachedData<T>>> {
183        let path = self.cache_path(name);
184        if !path.exists() {
185            return Ok(None);
186        }
187
188        let ciphertext = std::fs::read(&path)
189            .with_context(|| format!("Failed to read cache file: {}", name))?;
190
191        let plaintext = match decrypt_data(&ciphertext, &self.encryption_key) {
192            Ok(p) => p,
193            Err(e) => {
194                // Decryption failed - treat as cache miss (don't delete, key might be recoverable)
195                debug!(cache = name, error = %e, "Decryption failed, treating as cache miss");
196                return Ok(None);
197            }
198        };
199
200        let cached: CachedData<T> = serde_json::from_slice(&plaintext)
201            .with_context(|| format!("Failed to parse cache file: {}", name))?;
202
203        Ok(Some(cached))
204    }
205
206    fn save<T: Serialize>(&self, name: &str, data: &T) -> Result<()> {
207        let cached = CachedData::new(data);
208        let path = self.cache_path(name);
209        let plaintext = serde_json::to_vec(&cached)?;
210        let ciphertext = encrypt_data(&plaintext, &self.encryption_key)?;
211        std::fs::write(&path, ciphertext)?;
212        Ok(())
213    }
214
215    // ===== Youth =====
216
217    pub fn load_youth(&self) -> Result<Option<CachedData<Vec<Youth>>>> {
218        self.load("youth")
219    }
220
221    pub fn save_youth(&self, youth: &[Youth]) -> Result<()> {
222        self.save("youth", &youth)
223    }
224
225    // ===== Adults =====
226
227    pub fn load_adults(&self) -> Result<Option<CachedData<Vec<Adult>>>> {
228        self.load("adults")
229    }
230
231    pub fn save_adults(&self, adults: &[Adult]) -> Result<()> {
232        self.save("adults", &adults)
233    }
234
235    // ===== Parents =====
236
237    pub fn load_parents(&self) -> Result<Option<CachedData<Vec<Parent>>>> {
238        self.load("parents")
239    }
240
241    pub fn save_parents(&self, parents: &[Parent]) -> Result<()> {
242        self.save("parents", &parents)
243    }
244
245    // ===== Patrols =====
246
247    pub fn load_patrols(&self) -> Result<Option<CachedData<Vec<Patrol>>>> {
248        self.load("patrols")
249    }
250
251    pub fn save_patrols(&self, patrols: &[Patrol]) -> Result<()> {
252        self.save("patrols", &patrols)
253    }
254
255    // ===== Advancement Dashboard =====
256
257    pub fn load_advancement_dashboard(&self) -> Result<Option<CachedData<AdvancementDashboard>>> {
258        self.load("advancement_dashboard")
259    }
260
261    pub fn save_advancement_dashboard(&self, dashboard: &AdvancementDashboard) -> Result<()> {
262        self.save("advancement_dashboard", dashboard)
263    }
264
265    // ===== Ready to Award =====
266
267    pub fn load_ready_to_award(&self) -> Result<Option<CachedData<Vec<ReadyToAward>>>> {
268        self.load("ready_to_award")
269    }
270
271    pub fn save_ready_to_award(&self, awards: &[ReadyToAward]) -> Result<()> {
272        self.save("ready_to_award", &awards)
273    }
274
275    // ===== Events =====
276
277    pub fn load_events(&self) -> Result<Option<CachedData<Vec<Event>>>> {
278        self.load("events")
279    }
280
281    pub fn save_events(&self, events: &[Event]) -> Result<()> {
282        self.save("events", &events)
283    }
284
285    // ===== Individual Youth Progress =====
286
287    pub fn load_youth_ranks(&self, user_id: i64) -> Result<Option<CachedData<Vec<RankProgress>>>> {
288        self.load(&format!("ranks_{}", user_id))
289    }
290
291    pub fn save_youth_ranks(&self, user_id: i64, ranks: &[RankProgress]) -> Result<()> {
292        self.save(&format!("ranks_{}", user_id), &ranks)
293    }
294
295    pub fn load_youth_merit_badges(
296        &self,
297        user_id: i64,
298    ) -> Result<Option<CachedData<Vec<MeritBadgeProgress>>>> {
299        self.load(&format!("merit_badges_{}", user_id))
300    }
301
302    pub fn save_youth_merit_badges(
303        &self,
304        user_id: i64,
305        badges: &[MeritBadgeProgress],
306    ) -> Result<()> {
307        self.save(&format!("merit_badges_{}", user_id), &badges)
308    }
309
310    pub fn load_youth_leadership(
311        &self,
312        user_id: i64,
313    ) -> Result<Option<CachedData<Vec<LeadershipPosition>>>> {
314        self.load(&format!("leadership_{}", user_id))
315    }
316
317    pub fn save_youth_leadership(
318        &self,
319        user_id: i64,
320        positions: &[LeadershipPosition],
321    ) -> Result<()> {
322        self.save(&format!("leadership_{}", user_id), &positions)
323    }
324
325    pub fn load_youth_awards(
326        &self,
327        user_id: i64,
328    ) -> Result<Option<CachedData<Vec<Award>>>> {
329        self.load(&format!("awards_{}", user_id))
330    }
331
332    pub fn save_youth_awards(
333        &self,
334        user_id: i64,
335        awards: &[Award],
336    ) -> Result<()> {
337        self.save(&format!("awards_{}", user_id), &awards)
338    }
339
340    // ===== Unit Info =====
341
342    pub fn load_unit_info(&self) -> Result<Option<CachedData<UnitInfo>>> {
343        self.load("unit_info")
344    }
345
346    pub fn save_unit_info(&self, info: &UnitInfo) -> Result<()> {
347        self.save("unit_info", info)
348    }
349
350    // ===== Key3 =====
351
352    pub fn load_key3(&self) -> Result<Option<CachedData<Key3Leaders>>> {
353        self.load("key3")
354    }
355
356    pub fn save_key3(&self, key3: &Key3Leaders) -> Result<()> {
357        self.save("key3", key3)
358    }
359
360    // ===== Org Profile =====
361
362    pub fn load_org_profile(&self) -> Result<Option<CachedData<OrgProfile>>> {
363        self.load("org_profile")
364    }
365
366    pub fn save_org_profile(&self, profile: &OrgProfile) -> Result<()> {
367        self.save("org_profile", profile)
368    }
369
370    // ===== Commissioners =====
371
372    pub fn load_commissioners(&self) -> Result<Option<CachedData<Vec<Commissioner>>>> {
373        self.load("commissioners")
374    }
375
376    pub fn save_commissioners(&self, commissioners: &[Commissioner]) -> Result<()> {
377        self.save("commissioners", &commissioners)
378    }
379
380    // ===== Rank Requirements =====
381
382    pub fn load_rank_requirements(
383        &self,
384        user_id: i64,
385        rank_id: i64,
386    ) -> Result<Option<CachedData<Vec<crate::models::RankRequirement>>>> {
387        self.load(&format!("rank_reqs_{}_{}", user_id, rank_id))
388    }
389
390    pub fn save_rank_requirements(
391        &self,
392        user_id: i64,
393        rank_id: i64,
394        requirements: &[crate::models::RankRequirement],
395    ) -> Result<()> {
396        self.save(&format!("rank_reqs_{}_{}", user_id, rank_id), &requirements)
397    }
398
399    // ===== Badge Requirements =====
400
401    pub fn load_badge_requirements(
402        &self,
403        user_id: i64,
404        badge_id: i64,
405    ) -> Result<Option<CachedData<BadgeRequirementsData>>> {
406        self.load(&format!("badge_reqs_{}_{}", user_id, badge_id))
407    }
408
409    pub fn save_badge_requirements(
410        &self,
411        user_id: i64,
412        badge_id: i64,
413        requirements: &[crate::models::MeritBadgeRequirement],
414        version: &Option<String>,
415    ) -> Result<()> {
416        self.save(&format!("badge_reqs_{}_{}", user_id, badge_id), &(requirements, version))
417    }
418
419    // ===== Cache Age Information =====
420
421    /// Helper to load cache and log errors without failing
422    fn load_age<T>(&self, name: &str, loader: impl FnOnce() -> Result<Option<CachedData<T>>>) -> Option<String> {
423        match loader() {
424            Ok(Some(cached)) => Some(cached.age_display()),
425            Ok(None) => None,
426            Err(e) => {
427                debug!(cache = name, error = %e, "Failed to load cache for age display");
428                None
429            }
430        }
431    }
432
433    pub fn get_cache_ages(&self) -> CacheAges {
434        CacheAges {
435            youth: self.load_age("youth", || self.load_youth()),
436            adults: self.load_age("adults", || self.load_adults()),
437            parents: self.load_age("parents", || self.load_parents()),
438            patrols: self.load_age("patrols", || self.load_patrols()),
439            events: self.load_age("events", || self.load_events()),
440            advancement: self.load_age("advancement", || self.load_advancement_dashboard()),
441        }
442    }
443
444    /// Helper to check staleness and log errors without failing
445    fn is_cache_stale<T>(&self, name: &str, loader: impl FnOnce() -> Result<Option<CachedData<T>>>) -> bool {
446        match loader() {
447            Ok(Some(cached)) => cached.is_stale(),
448            Ok(None) => true, // No cache = stale
449            Err(e) => {
450                debug!(cache = name, error = %e, "Failed to load cache for staleness check");
451                true // Error reading = treat as stale
452            }
453        }
454    }
455
456    /// Check if any of the core cached data is stale
457    pub fn any_stale(&self) -> bool {
458        // Check all main cache types for staleness
459        let stale_checks = [
460            self.is_cache_stale("youth", || self.load_youth()),
461            self.is_cache_stale("adults", || self.load_adults()),
462            self.is_cache_stale("events", || self.load_events()),
463            self.is_cache_stale("patrols", || self.load_patrols()),
464            self.is_cache_stale("advancement", || self.load_advancement_dashboard()),
465        ];
466        stale_checks.iter().any(|&stale| stale)
467    }
468
469    /// Verify that essential cache files exist and are readable.
470    /// Returns a list of missing or unreadable cache files.
471    pub fn verify_cache(&self) -> Vec<String> {
472        let mut missing = Vec::new();
473
474        // Check essential cache files exist
475        let essential_files = ["youth", "adults", "events"];
476
477        for name in essential_files {
478            let path = self.cache_path(name);
479            if !path.exists() {
480                missing.push(format!("{} (file missing)", name));
481            }
482        }
483
484        // Also verify youth can be loaded (not just that file exists)
485        match self.load_youth() {
486            Ok(Some(data)) => {
487                debug!(count = data.data.len(), "Youth cache verified");
488            }
489            Ok(None) => {
490                if !missing.iter().any(|m| m.starts_with("youth")) {
491                    missing.push("youth (empty or unreadable)".to_string());
492                }
493            }
494            Err(e) => {
495                missing.push(format!("youth (error: {})", e));
496            }
497        }
498
499        missing
500    }
501
502    /// Get the cache directory path for diagnostic purposes.
503    pub fn cache_dir(&self) -> &PathBuf {
504        &self.cache_dir
505    }
506}
507
508#[derive(Debug, Default, serde::Serialize)]
509#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
510#[cfg_attr(feature = "ts", ts(export))]
511pub struct CacheAges {
512    pub youth: Option<String>,
513    pub adults: Option<String>,
514    #[allow(dead_code)]
515    pub parents: Option<String>,
516    #[allow(dead_code)]
517    pub patrols: Option<String>,
518    pub events: Option<String>,
519    #[allow(dead_code)]
520    pub advancement: Option<String>,
521}
522
523impl CacheAges {
524    #[allow(dead_code)]
525    pub fn roster_age(&self) -> String {
526        self.youth
527            .clone()
528            .or_else(|| self.adults.clone())
529            .unwrap_or_else(|| "never".to_string())
530    }
531
532    #[allow(dead_code)]
533    pub fn events_age(&self) -> String {
534        self.events.clone().unwrap_or_else(|| "never".to_string())
535    }
536
537    /// Returns the most recent update time across all cache types
538    pub fn last_updated(&self) -> String {
539        // Return the most recent (smallest age) from all cache types
540        let ages = [
541            &self.youth,
542            &self.adults,
543            &self.events,
544        ];
545
546        // Find any that has a value
547        if let Some(a) = ages.iter().copied().flatten().next() {
548            return a.clone();
549        }
550
551        "never".to_string()
552    }
553}
554
555// ============================================================================
556// Tests
557// ============================================================================
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use chrono::Duration;
563
564    #[test]
565    fn test_cached_data_age_display_just_now() {
566        let cached = CachedData::new(vec![1, 2, 3]);
567        // Just created, should be "just now"
568        assert_eq!(cached.age_display(), "just now");
569    }
570
571    #[test]
572    fn test_cached_data_is_stale() {
573        let fresh = CachedData::new(vec![1]);
574        assert!(!fresh.is_stale());
575
576        // Create a cached data that's 61 minutes old
577        let mut old = CachedData::new(vec![1]);
578        old.cached_at = Utc::now() - Duration::minutes(61);
579        assert!(old.is_stale());
580    }
581
582    #[test]
583    fn test_cached_data_age_minutes() {
584        let cached = CachedData::new(vec![1]);
585        // Should be 0 or very close to 0
586        assert!(cached.age_minutes() <= 1);
587    }
588
589    #[test]
590    fn test_cache_ages_last_updated_with_values() {
591        let ages = CacheAges {
592            youth: Some("5m ago".to_string()),
593            adults: None,
594            parents: None,
595            patrols: None,
596            events: None,
597            advancement: None,
598        };
599        assert_eq!(ages.last_updated(), "5m ago");
600    }
601
602    #[test]
603    fn test_cache_ages_last_updated_empty() {
604        let ages = CacheAges::default();
605        assert_eq!(ages.last_updated(), "never");
606    }
607}