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>
145 lines
5.6 KiB
Rust
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}"))
|
|
})
|
|
}
|
|
}
|