koprogo_api/infrastructure/storage/
file_storage.rs

1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use uuid::Uuid;
5
6/// File storage service for managing document uploads
7#[derive(Clone)]
8pub struct FileStorage {
9    base_path: PathBuf,
10}
11
12impl FileStorage {
13    /// Create a new FileStorage with the given base path
14    pub fn new(base_path: impl AsRef<Path>) -> Result<Self, String> {
15        let base_path = base_path.as_ref().to_path_buf();
16
17        // Create base directory if it doesn't exist
18        if !base_path.exists() {
19            fs::create_dir_all(&base_path)
20                .map_err(|e| format!("Failed to create storage directory: {}", e))?;
21        }
22
23        Ok(Self { base_path })
24    }
25
26    /// Save a file to storage and return the relative file path
27    /// Files are organized by building_id: /uploads/{building_id}/{filename}
28    pub async fn save_file(
29        &self,
30        building_id: Uuid,
31        filename: &str,
32        content: &[u8],
33    ) -> Result<String, String> {
34        // Create building-specific directory
35        let building_dir = self.base_path.join(building_id.to_string());
36        if !building_dir.exists() {
37            fs::create_dir_all(&building_dir)
38                .map_err(|e| format!("Failed to create building directory: {}", e))?;
39        }
40
41        // Generate unique filename to avoid collisions
42        let unique_filename = self.generate_unique_filename(filename);
43        let file_path = building_dir.join(&unique_filename);
44
45        // Write file to disk
46        let mut file =
47            fs::File::create(&file_path).map_err(|e| format!("Failed to create file: {}", e))?;
48
49        file.write_all(content)
50            .map_err(|e| format!("Failed to write file: {}", e))?;
51
52        // Return relative path (from base_path)
53        let relative_path = format!("{}/{}", building_id, unique_filename);
54        Ok(relative_path)
55    }
56
57    /// Read a file from storage
58    pub async fn read_file(&self, relative_path: &str) -> Result<Vec<u8>, String> {
59        let file_path = self.base_path.join(relative_path);
60
61        if !file_path.exists() {
62            return Err("File not found".to_string());
63        }
64
65        fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))
66    }
67
68    /// Delete a file from storage
69    pub async fn delete_file(&self, relative_path: &str) -> Result<(), String> {
70        let file_path = self.base_path.join(relative_path);
71
72        if !file_path.exists() {
73            return Err("File not found".to_string());
74        }
75
76        fs::remove_file(&file_path).map_err(|e| format!("Failed to delete file: {}", e))
77    }
78
79    /// Check if a file exists
80    pub async fn file_exists(&self, relative_path: &str) -> bool {
81        self.base_path.join(relative_path).exists()
82    }
83
84    /// Generate a unique filename by prepending UUID to original filename
85    fn generate_unique_filename(&self, original: &str) -> String {
86        let uuid = Uuid::new_v4();
87        format!("{}_{}", uuid, self.sanitize_filename(original))
88    }
89
90    /// Sanitize filename to prevent path traversal attacks
91    fn sanitize_filename(&self, filename: &str) -> String {
92        // Replace path separators and sanitize the filename
93        filename.replace("..", "_").replace(['/', '\\'], "_")
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::env;
101
102    #[tokio::test]
103    async fn test_save_and_read_file() {
104        let temp_dir = env::temp_dir().join("koprogo_test_storage");
105        let storage = FileStorage::new(&temp_dir).unwrap();
106
107        let building_id = Uuid::new_v4();
108        let content = b"Test file content";
109
110        let path = storage
111            .save_file(building_id, "test.txt", content)
112            .await
113            .unwrap();
114
115        assert!(storage.file_exists(&path).await);
116
117        let read_content = storage.read_file(&path).await.unwrap();
118        assert_eq!(read_content, content);
119
120        // Cleanup
121        storage.delete_file(&path).await.unwrap();
122        fs::remove_dir_all(&temp_dir).ok();
123    }
124
125    #[tokio::test]
126    async fn test_delete_file() {
127        let temp_dir = env::temp_dir().join("koprogo_test_storage_delete");
128        let storage = FileStorage::new(&temp_dir).unwrap();
129
130        let building_id = Uuid::new_v4();
131        let content = b"Test content";
132
133        let path = storage
134            .save_file(building_id, "delete_me.txt", content)
135            .await
136            .unwrap();
137
138        assert!(storage.file_exists(&path).await);
139
140        storage.delete_file(&path).await.unwrap();
141        assert!(!storage.file_exists(&path).await);
142
143        // Cleanup
144        fs::remove_dir_all(&temp_dir).ok();
145    }
146
147    #[test]
148    fn test_sanitize_filename() {
149        let temp_dir = env::temp_dir().join("koprogo_test");
150        let storage = FileStorage::new(&temp_dir).unwrap();
151
152        assert_eq!(storage.sanitize_filename("../etc/passwd"), "__etc_passwd");
153        assert_eq!(storage.sanitize_filename("normal.pdf"), "normal.pdf");
154
155        fs::remove_dir_all(&temp_dir).ok();
156    }
157}