use std::path::PathBuf; use base64::Engine; use rand::RngCore; use serde::{Deserialize, Serialize}; use crate::security::is_valid_key; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileMeta { pub id: String, pub original_name: String, pub size: u64, pub mime_type: String, pub download_key_hash: String, #[serde(skip_serializing_if = "Option::is_none")] pub password_hash: Option, pub created_at: String, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option, } pub fn is_expired(meta: &FileMeta, now_ms: i64) -> bool { match &meta.expires_at { Some(s) => chrono::DateTime::parse_from_rfc3339(s) .map(|dt| dt.timestamp_millis() <= now_ms) .unwrap_or(false), None => false, } } pub struct Storage { root: PathBuf, files_dir: PathBuf, metadata_dir: PathBuf, } impl Storage { pub fn new(root: PathBuf) -> Self { let files_dir = root.join("files"); let metadata_dir = root.join("metadata"); Self { root, files_dir, metadata_dir, } } pub async fn init(&self) -> std::io::Result<()> { tokio::fs::create_dir_all(&self.files_dir).await?; tokio::fs::create_dir_all(&self.metadata_dir).await?; Ok(()) } pub async fn load_secret(&self) -> std::io::Result { let path = self.root.join(".secret"); if let Ok(existing) = tokio::fs::read_to_string(&path).await { let existing = existing.trim().to_string(); if !existing.is_empty() { return Ok(existing); } } let mut bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut bytes); let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); #[cfg(unix)] { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut opts = std::fs::OpenOptions::new(); opts.write(true).create_new(true).mode(0o600); let mut f = opts.open(&path)?; f.write_all(secret.as_bytes())?; } #[cfg(not(unix))] { tokio::fs::write(&path, &secret).await?; } Ok(secret) } pub fn file_path(&self, id: &str) -> PathBuf { self.files_dir.join(id) } pub fn part_path(&self, id: &str) -> PathBuf { self.files_dir.join(format!("{id}.part")) } fn meta_path(&self, id: &str) -> PathBuf { self.metadata_dir.join(format!("{id}.json")) } pub async fn storage_usage_ratio(&self) -> f64 { match nix::sys::statvfs::statvfs(&self.root) { Ok(stats) => { let total = stats.blocks() * stats.fragment_size(); let avail = stats.blocks_available() * stats.fragment_size(); if total == 0 { return 0.0; } let ratio = 1.0 - (avail as f64 / total as f64); ratio.clamp(0.0, 1.0) } Err(_) => 0.0, } } pub async fn write_metadata(&self, meta: &FileMeta) -> std::io::Result<()> { let target = self.meta_path(&meta.id); let tmp = self.metadata_dir.join(format!("{}.json.tmp", meta.id)); let body = serde_json::to_string_pretty(meta) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; tokio::fs::write(&tmp, format!("{body}\n")).await?; tokio::fs::rename(&tmp, &target).await } pub async fn read_metadata(&self, id: &str) -> Option { if !is_valid_key(id) { return None; } let raw = tokio::fs::read_to_string(self.meta_path(id)).await.ok()?; let meta: FileMeta = serde_json::from_str(&raw).ok()?; if meta.id.is_empty() || meta.download_key_hash.is_empty() { return None; } Some(meta) } pub async fn delete(&self, id: &str) { let _ = tokio::fs::remove_file(self.file_path(id)).await; let _ = tokio::fs::remove_file(self.meta_path(id)).await; } pub async fn delete_partial(&self, id: &str) { let _ = tokio::fs::remove_file(self.part_path(id)).await; } pub async fn expire_expired(&self, now_ms: i64) -> usize { let mut removed = 0; let Ok(mut entries) = tokio::fs::read_dir(&self.metadata_dir).await else { return 0; }; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name(); let name = name.to_string_lossy(); if !name.ends_with(".json") { continue; } let id = &name[..name.len() - 5]; if !is_valid_key(id) { continue; } if let Some(meta) = self.read_metadata(id).await { if is_expired(&meta, now_ms) { self.delete(id).await; removed += 1; } } } removed } /// Consistency repair: remove orphans (file without metadata, metadata /// without file, leftover `.part` files). Never touches valid pairs. pub async fn sweep(&self) -> (usize, usize) { let mut removed_files = 0; let mut removed_metadata = 0; if let Ok(mut entries) = tokio::fs::read_dir(&self.files_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name(); let name = name.to_string_lossy().to_string(); if name.ends_with(".part") || !is_valid_key(&name) { let _ = tokio::fs::remove_file(entry.path()).await; removed_files += 1; continue; } if tokio::fs::metadata(self.metadata_dir.join(format!("{name}.json"))) .await .is_err() { let _ = tokio::fs::remove_file(entry.path()).await; removed_files += 1; } } } if let Ok(mut entries) = tokio::fs::read_dir(&self.metadata_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name(); let name = name.to_string_lossy().to_string(); if !name.ends_with(".json") { let _ = tokio::fs::remove_file(entry.path()).await; removed_metadata += 1; continue; } let id = &name[..name.len() - 5]; if !is_valid_key(id) { let _ = tokio::fs::remove_file(entry.path()).await; removed_metadata += 1; continue; } if tokio::fs::metadata(self.files_dir.join(id)).await.is_err() { let _ = tokio::fs::remove_file(entry.path()).await; removed_metadata += 1; } } } (removed_files, removed_metadata) } }