Skip to main content

fraiseql_server/files/storage/
local.rs

1//! Local filesystem storage backend
2
3use std::{collections::HashMap, path::PathBuf, time::Duration};
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use tokio::fs;
8
9use crate::files::{
10    config::StorageConfig,
11    error::StorageError,
12    traits::{StorageBackend, StorageMetadata, StorageResult},
13};
14
15pub struct LocalStorage {
16    base_path: PathBuf,
17    serve_url: String,
18}
19
20impl LocalStorage {
21    pub fn new(config: &StorageConfig) -> Result<Self, StorageError> {
22        let base_path = config
23            .base_path
24            .as_ref()
25            .map(PathBuf::from)
26            .unwrap_or_else(|| PathBuf::from("./uploads"));
27
28        let serve_url = config.serve_path.clone().unwrap_or_else(|| "/files".to_string());
29
30        // Create directory if it doesn't exist
31        std::fs::create_dir_all(&base_path).map_err(|e| StorageError::Configuration {
32            message: format!("Failed to create upload directory: {}", e),
33        })?;
34
35        Ok(Self {
36            base_path,
37            serve_url,
38        })
39    }
40}
41
42#[async_trait]
43impl StorageBackend for LocalStorage {
44    fn name(&self) -> &'static str {
45        "local"
46    }
47
48    async fn upload(
49        &self,
50        key: &str,
51        data: Bytes,
52        _content_type: &str,
53        _metadata: Option<&StorageMetadata>,
54    ) -> Result<StorageResult, StorageError> {
55        let path = self.base_path.join(key);
56
57        // Create parent directories
58        if let Some(parent) = path.parent() {
59            fs::create_dir_all(parent).await.map_err(|e| StorageError::UploadFailed {
60                message: e.to_string(),
61            })?;
62        }
63
64        fs::write(&path, &data).await.map_err(|e| StorageError::UploadFailed {
65            message: e.to_string(),
66        })?;
67
68        Ok(StorageResult {
69            key:  key.to_string(),
70            url:  self.public_url(key),
71            etag: None,
72            size: data.len() as u64,
73        })
74    }
75
76    async fn download(&self, key: &str) -> Result<Bytes, StorageError> {
77        let path = self.base_path.join(key);
78
79        let data = fs::read(&path).await.map_err(|e| {
80            if e.kind() == std::io::ErrorKind::NotFound {
81                StorageError::NotFound {
82                    key: key.to_string(),
83                }
84            } else {
85                StorageError::DownloadFailed {
86                    message: e.to_string(),
87                }
88            }
89        })?;
90
91        Ok(Bytes::from(data))
92    }
93
94    async fn delete(&self, key: &str) -> Result<(), StorageError> {
95        let path = self.base_path.join(key);
96
97        fs::remove_file(&path).await.map_err(|e| StorageError::Provider {
98            message: e.to_string(),
99        })?;
100
101        Ok(())
102    }
103
104    async fn exists(&self, key: &str) -> Result<bool, StorageError> {
105        let path = self.base_path.join(key);
106        Ok(path.exists())
107    }
108
109    async fn metadata(&self, key: &str) -> Result<StorageMetadata, StorageError> {
110        let path = self.base_path.join(key);
111
112        let meta = fs::metadata(&path).await.map_err(|e| StorageError::Provider {
113            message: e.to_string(),
114        })?;
115
116        let last_modified = meta.modified().ok().and_then(|t| {
117            let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
118            chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
119        });
120
121        Ok(StorageMetadata {
122            content_type: mime_guess::from_path(&path).first_or_octet_stream().to_string(),
123            content_length: meta.len(),
124            etag: None,
125            last_modified,
126            custom: HashMap::new(),
127        })
128    }
129
130    async fn signed_url(&self, key: &str, _expiry: Duration) -> Result<String, StorageError> {
131        // Local storage doesn't support signed URLs in production
132        // For dev, just return the public URL
133        Ok(self.public_url(key))
134    }
135
136    fn public_url(&self, key: &str) -> String {
137        format!("{}/{}", self.serve_url, key)
138    }
139}