//! Lokaler Dateisystem-Adapter für `SignatureStorage`. //! //! Schreibt jede Unterschrift als PNG unter //! `/_.png`. Der Pfad ist deterministisch: //! ein Retry überschreibt dieselbe Datei, statt Müll anzuhäufen. Persistiert //! wird in der DB nur die relative Referenz (Dateiname), nicht der absolute //! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert. use std::path::PathBuf; use std::time::{Duration, SystemTime}; use async_trait::async_trait; use uuid::Uuid; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::{SignatureRole, SignatureStorage}; pub struct LocalSignatureStorage { base_dir: PathBuf, } impl LocalSignatureStorage { /// Legt das Basis-Verzeichnis (rekursiv) an, falls es noch nicht /// existiert. Ein nicht-anlegbares Verzeichnis ist ein Boot-Fehler. pub fn new(base_dir: impl Into) -> std::io::Result { let base_dir = base_dir.into(); std::fs::create_dir_all(&base_dir)?; Ok(Self { base_dir }) } fn file_name(delivery_id: Uuid, role: SignatureRole) -> String { format!("{delivery_id}_{}.png", role.as_str()) } } #[async_trait] impl SignatureStorage for LocalSignatureStorage { async fn save( &self, delivery_id: Uuid, role: SignatureRole, bytes: Vec, ) -> Result { let name = Self::file_name(delivery_id, role); let path = self.base_dir.join(&name); // Blocking-IO bewusst auf einen Blocking-Thread auslagern. tokio::task::spawn_blocking(move || std::fs::write(&path, &bytes)) .await .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? .map_err(|e| { ApplicationError::Repository(format!("signatur speichern fehlgeschlagen: {e}")) })?; Ok(name) } async fn load(&self, reference: &str) -> Result>, ApplicationError> { // Traversal-Schutz: Referenz ist ein einfacher Dateiname. if reference.contains('/') || reference.contains('\\') || reference.contains("..") { return Err(ApplicationError::Validation( "ungültige Signatur-Referenz".into(), )); } let path = self.base_dir.join(reference); let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path)) .await .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))?; match bytes { Ok(b) => Ok(Some(b)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(ApplicationError::Repository(format!( "signatur laden fehlgeschlagen: {e}" ))), } } async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError> { // Beide deterministisch benannten Dateien (Kunde + Fahrer) entfernen. let paths = [ self.base_dir .join(Self::file_name(delivery_id, SignatureRole::Customer)), self.base_dir .join(Self::file_name(delivery_id, SignatureRole::Driver)), ]; tokio::task::spawn_blocking(move || -> std::io::Result<()> { for path in paths { match std::fs::remove_file(&path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), } } Ok(()) }) .await .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? .map_err(|e| { ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}")) }) } async fn delete_older_than(&self, max_age: Duration) -> Result { let base = self.base_dir.clone(); tokio::task::spawn_blocking(move || -> std::io::Result { let now = SystemTime::now(); let entries = match std::fs::read_dir(&base) { Ok(e) => e, // Verzeichnis (noch) nicht vorhanden → nichts zu löschen. Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), Err(e) => return Err(e), }; let mut deleted = 0u64; for entry in entries { let entry = entry?; let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; if !meta.is_file() { continue; } // Alter über die letzte Änderung (mtime) bestimmen. Lässt sich // die mtime nicht lesen, Datei vorsichtshalber NICHT löschen. let modified = match meta.modified() { Ok(t) => t, Err(_) => continue, }; let age = now.duration_since(modified).unwrap_or(Duration::ZERO); if age > max_age { match std::fs::remove_file(entry.path()) { Ok(()) => deleted += 1, Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), } } } Ok(deleted) }) .await .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? .map_err(|e| { ApplicationError::Repository(format!("signatur-cleanup fehlgeschlagen: {e}")) }) } }