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
20pub type BadgeRequirementsData = (Vec<MeritBadgeRequirement>, Option<String>);
22
23const NONCE_SIZE: usize = 12; fn derive_key_from_password(password: &str, salt: &str) -> Result<[u8; 32]> {
29 let mut key = [0u8; 32];
30 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
37fn encrypt_data(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
40 let cipher = ChaCha20Poly1305::new(key.into());
41
42 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 let ciphertext = cipher
49 .encrypt(nonce, plaintext)
50 .map_err(|e| anyhow!("Encryption failed: {}", e))?;
51
52 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
60fn 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 let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);
75 let ciphertext = &data[NONCE_SIZE..];
76
77 let plaintext = cipher
79 .decrypt(nonce, ciphertext)
80 .map_err(|e| anyhow!("Decryption failed: {}", e))?;
81
82 Ok(plaintext)
83}
84
85const 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 "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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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, Err(e) => {
450 debug!(cache = name, error = %e, "Failed to load cache for staleness check");
451 true }
453 }
454 }
455
456 pub fn any_stale(&self) -> bool {
458 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 pub fn verify_cache(&self) -> Vec<String> {
472 let mut missing = Vec::new();
473
474 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 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 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 pub fn last_updated(&self) -> String {
539 let ages = [
541 &self.youth,
542 &self.adults,
543 &self.events,
544 ];
545
546 if let Some(a) = ages.iter().copied().flatten().next() {
548 return a.clone();
549 }
550
551 "never".to_string()
552 }
553}
554
555#[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 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 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 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}