Skip to main content

pylon_storage/
files.rs

1//! Pluggable file storage abstraction.
2//!
3//! Provides a trait for file storage operations that can be implemented
4//! for local disk, S3, R2, GCS, or any other storage backend.
5
6use serde::Serialize;
7use std::io::Read;
8
9// ---------------------------------------------------------------------------
10// File storage trait
11// ---------------------------------------------------------------------------
12
13/// Pluggable file storage backend.
14pub trait FileStorage: Send + Sync {
15    /// Store file content, returning a file ID and public URL.
16    fn store(
17        &self,
18        name: &str,
19        content: &[u8],
20        content_type: &str,
21    ) -> Result<StoredFile, FileStorageError>;
22
23    /// Retrieve file content by ID.
24    fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError>;
25
26    /// Delete a file by ID.
27    fn delete(&self, id: &str) -> Result<bool, FileStorageError>;
28
29    /// Generate a presigned upload URL (for direct client uploads).
30    /// Not all backends support this — returns None if unsupported.
31    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
62// ---------------------------------------------------------------------------
63// Local filesystem implementation
64// ---------------------------------------------------------------------------
65
66/// File storage backed by a local directory.
67pub 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// ---------------------------------------------------------------------------
145// S3-compatible storage (stub — needs an HTTP client at runtime)
146// ---------------------------------------------------------------------------
147
148/// Configuration for S3-compatible storage (S3, R2, GCS, MinIO).
149#[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    /// Create from environment variables.
161    ///
162    /// Reads: PYLON_S3_BUCKET, PYLON_S3_REGION, PYLON_S3_ENDPOINT,
163    /// PYLON_S3_ACCESS_KEY, PYLON_S3_SECRET_KEY, PYLON_S3_PUBLIC_URL
164    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
176// ---------------------------------------------------------------------------
177// Stack0 CDN/storage implementation
178// ---------------------------------------------------------------------------
179
180/// File storage backed by Stack0's CDN.
181///
182/// Uploads use a 3-step flow: POST `/cdn/upload` to mint a presigned URL,
183/// PUT the bytes to that URL, then POST `/cdn/upload/{assetId}/confirm`.
184/// `store()` returns the public `cdnUrl` so clients can embed it directly
185/// without round-tripping through pylon.
186pub struct Stack0FileStorage {
187    api_key: String,
188    /// Base API URL — typically `https://api.stack0.dev`. Configurable so
189    /// tests can point at a local mock server.
190    base_url: String,
191    /// Optional folder/prefix for organizing uploads.
192    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    /// Override the API base URL (useful for tests or self-hosted Stack0).
210    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    /// Construct from environment variables.
216    /// Reads: PYLON_STACK0_API_KEY (required), PYLON_STACK0_FOLDER (optional),
217    /// PYLON_STACK0_BASE_URL (optional override).
218    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    /// JSON body for the `/cdn/upload` init call. Pulled out so tests can
231    /// pin the wire shape without exercising the network.
232    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        // 1. Mint presigned upload URL.
277        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        // 2. PUT bytes to presigned URL. The presigned URL carries its own
299        // signature, so we don't reattach the API key here.
300        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        // 3. Confirm upload so Stack0 marks the asset as ready.
307        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        // Two-step recovery path: look up the asset's cdnUrl, then fetch bytes.
325        // Most callers should embed the cdnUrl returned from `store()` directly
326        // and never hit this method.
327        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}