1use serde::Serialize;
7use std::io::Read;
8
9pub trait FileStorage: Send + Sync {
15 fn store(
17 &self,
18 name: &str,
19 content: &[u8],
20 content_type: &str,
21 ) -> Result<StoredFile, FileStorageError>;
22
23 fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError>;
25
26 fn delete(&self, id: &str) -> Result<bool, FileStorageError>;
28
29 fn presigned_upload_url(
32 &self,
33 _name: &str,
34 _content_type: &str,
35 _expires_secs: u64,
36 ) -> Result<Option<String>, FileStorageError> {
37 Ok(None)
38 }
39}
40
41#[derive(Debug, Clone, Serialize)]
42pub struct StoredFile {
43 pub id: String,
44 pub url: String,
45 pub size: usize,
46}
47
48#[derive(Debug, Clone)]
49pub struct FileStorageError {
50 pub code: String,
51 pub message: String,
52}
53
54impl std::fmt::Display for FileStorageError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "[{}] {}", self.code, self.message)
57 }
58}
59
60impl std::error::Error for FileStorageError {}
61
62pub struct LocalFileStorage {
68 dir: std::path::PathBuf,
69 url_prefix: String,
70}
71
72impl LocalFileStorage {
73 pub fn new(dir: &str, url_prefix: &str) -> Self {
74 let path = std::path::PathBuf::from(dir);
75 let _ = std::fs::create_dir_all(&path);
76 Self {
77 dir: path,
78 url_prefix: url_prefix.to_string(),
79 }
80 }
81}
82
83impl FileStorage for LocalFileStorage {
84 fn store(
85 &self,
86 name: &str,
87 content: &[u8],
88 _content_type: &str,
89 ) -> Result<StoredFile, FileStorageError> {
90 let id = format!(
91 "file_{}_{}",
92 std::time::SystemTime::now()
93 .duration_since(std::time::UNIX_EPOCH)
94 .unwrap_or_default()
95 .as_nanos(),
96 name.replace(['/', '\\', '.'], "_")
97 );
98 let path = self.dir.join(&id);
99 std::fs::write(&path, content).map_err(|e| FileStorageError {
100 code: "WRITE_FAILED".into(),
101 message: format!("Failed to write file: {e}"),
102 })?;
103
104 Ok(StoredFile {
105 url: format!("{}/{}", self.url_prefix, id),
106 size: content.len(),
107 id,
108 })
109 }
110
111 fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError> {
112 if id.contains("..") || id.contains('/') || id.contains('\\') {
113 return Err(FileStorageError {
114 code: "INVALID_ID".into(),
115 message: "Invalid file ID".into(),
116 });
117 }
118 let path = self.dir.join(id);
119 std::fs::read(&path).map_err(|_| FileStorageError {
120 code: "NOT_FOUND".into(),
121 message: "File not found".into(),
122 })
123 }
124
125 fn delete(&self, id: &str) -> Result<bool, FileStorageError> {
126 if id.contains("..") || id.contains('/') || id.contains('\\') {
127 return Err(FileStorageError {
128 code: "INVALID_ID".into(),
129 message: "Invalid file ID".into(),
130 });
131 }
132 let path = self.dir.join(id);
133 match std::fs::remove_file(&path) {
134 Ok(()) => Ok(true),
135 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
136 Err(e) => Err(FileStorageError {
137 code: "DELETE_FAILED".into(),
138 message: format!("Failed to delete file: {e}"),
139 }),
140 }
141 }
142}
143
144#[derive(Debug, Clone)]
150pub struct S3Config {
151 pub bucket: String,
152 pub region: String,
153 pub endpoint: Option<String>,
154 pub access_key: String,
155 pub secret_key: String,
156 pub public_url_prefix: Option<String>,
157}
158
159impl S3Config {
160 pub fn from_env() -> Option<Self> {
165 Some(Self {
166 bucket: std::env::var("PYLON_S3_BUCKET").ok()?,
167 region: std::env::var("PYLON_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
168 endpoint: std::env::var("PYLON_S3_ENDPOINT").ok(),
169 access_key: std::env::var("PYLON_S3_ACCESS_KEY").ok()?,
170 secret_key: std::env::var("PYLON_S3_SECRET_KEY").ok()?,
171 public_url_prefix: std::env::var("PYLON_S3_PUBLIC_URL").ok(),
172 })
173 }
174}
175
176pub struct Stack0FileStorage {
187 api_key: String,
188 base_url: String,
191 folder: Option<String>,
193}
194
195impl Stack0FileStorage {
196 pub fn new(api_key: impl Into<String>) -> Self {
197 Self {
198 api_key: api_key.into(),
199 base_url: "https://api.stack0.dev".into(),
200 folder: None,
201 }
202 }
203
204 pub fn with_folder(mut self, folder: impl Into<String>) -> Self {
205 self.folder = Some(folder.into());
206 self
207 }
208
209 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
211 self.base_url = base_url.into();
212 self
213 }
214
215 pub fn from_env() -> Option<Self> {
219 let api_key = std::env::var("PYLON_STACK0_API_KEY").ok()?;
220 let mut s = Self::new(api_key);
221 if let Ok(folder) = std::env::var("PYLON_STACK0_FOLDER") {
222 s = s.with_folder(folder);
223 }
224 if let Ok(base) = std::env::var("PYLON_STACK0_BASE_URL") {
225 s = s.with_base_url(base);
226 }
227 Some(s)
228 }
229
230 pub fn build_upload_init_body(
233 &self,
234 filename: &str,
235 content_type: &str,
236 size: usize,
237 ) -> serde_json::Value {
238 let mut body = serde_json::json!({
239 "filename": filename,
240 "mimeType": content_type,
241 "size": size,
242 });
243 if let Some(folder) = &self.folder {
244 body["folder"] = serde_json::Value::String(folder.clone());
245 }
246 body
247 }
248}
249
250fn stack0_agent() -> ureq::Agent {
251 ureq::AgentBuilder::new()
252 .timeout_connect(std::time::Duration::from_secs(10))
253 .timeout_read(std::time::Duration::from_secs(30))
254 .timeout_write(std::time::Duration::from_secs(30))
255 .user_agent("pylon-storage/0.1")
256 .build()
257}
258
259fn stack0_err(code: &str, e: impl std::fmt::Display) -> FileStorageError {
260 FileStorageError {
261 code: code.into(),
262 message: e.to_string(),
263 }
264}
265
266impl FileStorage for Stack0FileStorage {
267 fn store(
268 &self,
269 name: &str,
270 content: &[u8],
271 content_type: &str,
272 ) -> Result<StoredFile, FileStorageError> {
273 let agent = stack0_agent();
274 let init_body = self.build_upload_init_body(name, content_type, content.len());
275
276 let init_resp: serde_json::Value = agent
278 .post(&format!("{}/cdn/upload", self.base_url))
279 .set("Authorization", &format!("Bearer {}", self.api_key))
280 .set("Content-Type", "application/json")
281 .send_string(&init_body.to_string())
282 .map_err(|e| stack0_err("STACK0_UPLOAD_INIT_FAILED", e))?
283 .into_json()
284 .map_err(|e| stack0_err("STACK0_UPLOAD_INIT_PARSE", e))?;
285
286 let upload_url = init_resp["uploadUrl"]
287 .as_str()
288 .ok_or_else(|| stack0_err("STACK0_UPLOAD_INIT_BAD_RESPONSE", "missing uploadUrl"))?;
289 let asset_id = init_resp["assetId"]
290 .as_str()
291 .ok_or_else(|| stack0_err("STACK0_UPLOAD_INIT_BAD_RESPONSE", "missing assetId"))?
292 .to_string();
293 let cdn_url = init_resp["cdnUrl"]
294 .as_str()
295 .ok_or_else(|| stack0_err("STACK0_UPLOAD_INIT_BAD_RESPONSE", "missing cdnUrl"))?
296 .to_string();
297
298 agent
301 .put(upload_url)
302 .set("Content-Type", content_type)
303 .send_bytes(content)
304 .map_err(|e| stack0_err("STACK0_UPLOAD_PUT_FAILED", e))?;
305
306 agent
308 .post(&format!(
309 "{}/cdn/upload/{}/confirm",
310 self.base_url, asset_id
311 ))
312 .set("Authorization", &format!("Bearer {}", self.api_key))
313 .call()
314 .map_err(|e| stack0_err("STACK0_UPLOAD_CONFIRM_FAILED", e))?;
315
316 Ok(StoredFile {
317 id: asset_id,
318 url: cdn_url,
319 size: content.len(),
320 })
321 }
322
323 fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError> {
324 let agent = stack0_agent();
328 let meta: serde_json::Value = agent
329 .get(&format!("{}/cdn/assets/{}", self.base_url, id))
330 .set("Authorization", &format!("Bearer {}", self.api_key))
331 .call()
332 .map_err(|e| match &e {
333 ureq::Error::Status(404, _) => stack0_err("NOT_FOUND", "Asset not found"),
334 _ => stack0_err("STACK0_GET_FAILED", e),
335 })?
336 .into_json()
337 .map_err(|e| stack0_err("STACK0_GET_PARSE", e))?;
338
339 let cdn_url = meta["cdnUrl"]
340 .as_str()
341 .ok_or_else(|| stack0_err("STACK0_GET_BAD_RESPONSE", "missing cdnUrl"))?;
342
343 let mut buf = Vec::new();
344 agent
345 .get(cdn_url)
346 .call()
347 .map_err(|e| stack0_err("STACK0_FETCH_FAILED", e))?
348 .into_reader()
349 .read_to_end(&mut buf)
350 .map_err(|e| stack0_err("STACK0_FETCH_READ", e))?;
351 Ok(buf)
352 }
353
354 fn delete(&self, id: &str) -> Result<bool, FileStorageError> {
355 let agent = stack0_agent();
356 match agent
357 .delete(&format!("{}/cdn/assets/{}", self.base_url, id))
358 .set("Authorization", &format!("Bearer {}", self.api_key))
359 .call()
360 {
361 Ok(_) => Ok(true),
362 Err(ureq::Error::Status(404, _)) => Ok(false),
363 Err(e) => Err(stack0_err("STACK0_DELETE_FAILED", e)),
364 }
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn local_store_and_get() {
374 let dir = std::env::temp_dir().join(format!("pylon_files_{}", std::process::id()));
375 let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
376
377 let stored = storage
378 .store("test.txt", b"hello world", "text/plain")
379 .unwrap();
380 assert_eq!(stored.size, 11);
381 assert!(stored.url.starts_with("/api/files/"));
382
383 let content = storage.get(&stored.id).unwrap();
384 assert_eq!(content, b"hello world");
385
386 let deleted = storage.delete(&stored.id).unwrap();
387 assert!(deleted);
388
389 let not_found = storage.get(&stored.id);
390 assert!(not_found.is_err());
391
392 let _ = std::fs::remove_dir_all(&dir);
393 }
394
395 #[test]
396 fn local_rejects_traversal() {
397 let dir = std::env::temp_dir().join(format!("pylon_files2_{}", std::process::id()));
398 let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
399
400 assert!(storage.get("../etc/passwd").is_err());
401 assert!(storage.delete("../etc/passwd").is_err());
402
403 let _ = std::fs::remove_dir_all(&dir);
404 }
405
406 #[test]
407 fn stack0_upload_init_body_shape() {
408 let storage = Stack0FileStorage::new("sk_test_123");
409 let body = storage.build_upload_init_body("photo.jpg", "image/jpeg", 4096);
410 assert_eq!(body["filename"], "photo.jpg");
411 assert_eq!(body["mimeType"], "image/jpeg");
412 assert_eq!(body["size"], 4096);
413 assert!(body.get("folder").is_none());
414 }
415
416 #[test]
417 fn stack0_upload_init_body_includes_folder() {
418 let storage = Stack0FileStorage::new("sk_test_123").with_folder("avatars");
419 let body = storage.build_upload_init_body("photo.jpg", "image/jpeg", 4096);
420 assert_eq!(body["folder"], "avatars");
421 }
422
423 #[test]
424 fn stack0_default_base_url() {
425 let storage = Stack0FileStorage::new("sk_test_123");
426 assert_eq!(storage.base_url, "https://api.stack0.dev");
427 }
428
429 #[test]
430 fn stack0_with_base_url_override() {
431 let storage = Stack0FileStorage::new("sk_test_123").with_base_url("http://localhost:9999");
432 assert_eq!(storage.base_url, "http://localhost:9999");
433 }
434}