koprogo_api/infrastructure/storage/
file_storage.rs

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