Files
Holzleitner---Backend--aktu…/crates/infrastructure/src/storage/signature_storage.rs
Dennis Nemec 47eb8ec57d feat(signature): Signaturen beim Report-Upload behalten + Cron-Cleanup nach Frist
Bisher loeschte die Report-Pipeline die Unterschriften nach erfolgreichem
DOCUframe-Upload. Wir brauchen die Signatur-Dateien aber weiterhin, daher:

- ProcessDeliveryReportUseCase: Signatur-Loeschung (delete_for_delivery) aus dem
  Cleanup entfernt + SignatureStorage-Dependency raus (Report-PDF/Bild-Notiz-
  Cleanup bleibt).
- SignatureStorage: neue Methode delete_older_than(max_age) -> Anzahl; lokaler
  Adapter loescht PNGs aelter als die Frist (per mtime).
- Config [signature]: retention_days (Default 90, 0 = aus) + cleanup_cron
  (Default taeglich 04:00).
- main.rs: Signatur-Cleanup-Scheduler (gated retention_days > 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:16:12 +02:00

145 lines
5.6 KiB
Rust

//! Lokaler Dateisystem-Adapter für `SignatureStorage`.
//!
//! Schreibt jede Unterschrift als PNG unter
//! `<base_dir>/<delivery_id>_<role>.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<PathBuf>) -> std::io::Result<Self> {
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<u8>,
) -> Result<String, ApplicationError> {
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<Option<Vec<u8>>, 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<u64, ApplicationError> {
let base = self.base_dir.clone();
tokio::task::spawn_blocking(move || -> std::io::Result<u64> {
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}"))
})
}
}