1use serde::Serialize;
7
8pub trait FileStorage: Send + Sync {
14 fn store(
16 &self,
17 name: &str,
18 content: &[u8],
19 content_type: &str,
20 ) -> Result<StoredFile, FileStorageError>;
21
22 fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError>;
24
25 fn delete(&self, id: &str) -> Result<bool, FileStorageError>;
27
28 fn presigned_upload_url(
31 &self,
32 _name: &str,
33 _content_type: &str,
34 _expires_secs: u64,
35 ) -> Result<Option<String>, FileStorageError> {
36 Ok(None)
37 }
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct StoredFile {
42 pub id: String,
43 pub url: String,
44 pub size: usize,
45}
46
47#[derive(Debug, Clone)]
48pub struct FileStorageError {
49 pub code: String,
50 pub message: String,
51}
52
53impl std::fmt::Display for FileStorageError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 write!(f, "[{}] {}", self.code, self.message)
56 }
57}
58
59impl std::error::Error for FileStorageError {}
60
61pub struct LocalFileStorage {
67 dir: std::path::PathBuf,
68 url_prefix: String,
69}
70
71impl LocalFileStorage {
72 pub fn new(dir: &str, url_prefix: &str) -> Self {
73 let path = std::path::PathBuf::from(dir);
74 let _ = std::fs::create_dir_all(&path);
75 Self {
76 dir: path,
77 url_prefix: url_prefix.to_string(),
78 }
79 }
80}
81
82impl FileStorage for LocalFileStorage {
83 fn store(
84 &self,
85 name: &str,
86 content: &[u8],
87 _content_type: &str,
88 ) -> Result<StoredFile, FileStorageError> {
89 let id = format!(
90 "file_{}_{}",
91 std::time::SystemTime::now()
92 .duration_since(std::time::UNIX_EPOCH)
93 .unwrap_or_default()
94 .as_nanos(),
95 name.replace(['/', '\\', '.'], "_")
96 );
97 let path = self.dir.join(&id);
98 std::fs::write(&path, content).map_err(|e| FileStorageError {
99 code: "WRITE_FAILED".into(),
100 message: format!("Failed to write file: {e}"),
101 })?;
102
103 Ok(StoredFile {
104 url: format!("{}/{}", self.url_prefix, id),
105 size: content.len(),
106 id,
107 })
108 }
109
110 fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError> {
111 if id.contains("..") || id.contains('/') || id.contains('\\') {
112 return Err(FileStorageError {
113 code: "INVALID_ID".into(),
114 message: "Invalid file ID".into(),
115 });
116 }
117 let path = self.dir.join(id);
118 std::fs::read(&path).map_err(|_| FileStorageError {
119 code: "NOT_FOUND".into(),
120 message: "File not found".into(),
121 })
122 }
123
124 fn delete(&self, id: &str) -> Result<bool, FileStorageError> {
125 if id.contains("..") || id.contains('/') || id.contains('\\') {
126 return Err(FileStorageError {
127 code: "INVALID_ID".into(),
128 message: "Invalid file ID".into(),
129 });
130 }
131 let path = self.dir.join(id);
132 match std::fs::remove_file(&path) {
133 Ok(()) => Ok(true),
134 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
135 Err(e) => Err(FileStorageError {
136 code: "DELETE_FAILED".into(),
137 message: format!("Failed to delete file: {e}"),
138 }),
139 }
140 }
141}
142
143#[derive(Debug, Clone)]
149pub struct S3Config {
150 pub bucket: String,
151 pub region: String,
152 pub endpoint: Option<String>,
153 pub access_key: String,
154 pub secret_key: String,
155 pub public_url_prefix: Option<String>,
156}
157
158impl S3Config {
159 pub fn from_env() -> Option<Self> {
164 Some(Self {
165 bucket: std::env::var("PYLON_S3_BUCKET").ok()?,
166 region: std::env::var("PYLON_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
167 endpoint: std::env::var("PYLON_S3_ENDPOINT").ok(),
168 access_key: std::env::var("PYLON_S3_ACCESS_KEY").ok()?,
169 secret_key: std::env::var("PYLON_S3_SECRET_KEY").ok()?,
170 public_url_prefix: std::env::var("PYLON_S3_PUBLIC_URL").ok(),
171 })
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn local_store_and_get() {
181 let dir = std::env::temp_dir().join(format!("pylon_files_{}", std::process::id()));
182 let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
183
184 let stored = storage
185 .store("test.txt", b"hello world", "text/plain")
186 .unwrap();
187 assert_eq!(stored.size, 11);
188 assert!(stored.url.starts_with("/api/files/"));
189
190 let content = storage.get(&stored.id).unwrap();
191 assert_eq!(content, b"hello world");
192
193 let deleted = storage.delete(&stored.id).unwrap();
194 assert!(deleted);
195
196 let not_found = storage.get(&stored.id);
197 assert!(not_found.is_err());
198
199 let _ = std::fs::remove_dir_all(&dir);
200 }
201
202 #[test]
203 fn local_rejects_traversal() {
204 let dir = std::env::temp_dir().join(format!("pylon_files2_{}", std::process::id()));
205 let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
206
207 assert!(storage.get("../etc/passwd").is_err());
208 assert!(storage.delete("../etc/passwd").is_err());
209
210 let _ = std::fs::remove_dir_all(&dir);
211 }
212}