Skip to main content

ferro_storage/
config.rs

1//! Configuration for the storage system.
2
3use crate::facade::DiskConfig;
4#[cfg(feature = "s3")]
5use crate::facade::DiskDriver;
6use std::collections::HashMap;
7use std::env;
8
9/// Configuration for the storage system.
10#[derive(Debug, Clone)]
11pub struct StorageConfig {
12    /// Default disk name.
13    pub default: String,
14    /// Disk configurations.
15    pub disks: HashMap<String, DiskConfig>,
16}
17
18impl Default for StorageConfig {
19    fn default() -> Self {
20        let mut disks = HashMap::new();
21        disks.insert("local".to_string(), DiskConfig::local("./storage"));
22
23        Self {
24            default: "local".to_string(),
25            disks,
26        }
27    }
28}
29
30impl StorageConfig {
31    /// Create a new storage config with a default disk.
32    pub fn new(default: impl Into<String>) -> Self {
33        Self {
34            default: default.into(),
35            disks: HashMap::new(),
36        }
37    }
38
39    /// Create configuration from environment variables.
40    ///
41    /// Reads the following environment variables:
42    /// - `FILESYSTEM_DISK`: Default disk name (default: "local")
43    /// - `FILESYSTEM_LOCAL_ROOT`: Root path for local disk (default: "./storage")
44    /// - `FILESYSTEM_LOCAL_URL`: Public URL for local files
45    /// - `FILESYSTEM_PUBLIC_ROOT`: Root path for public disk (default: "./storage/public")
46    /// - `FILESYSTEM_PUBLIC_URL`: Public URL for public files (default: "/storage")
47    ///
48    /// With `s3` feature:
49    /// - `AWS_ACCESS_KEY_ID`: S3 access key
50    /// - `AWS_SECRET_ACCESS_KEY`: S3 secret key
51    /// - `AWS_DEFAULT_REGION`: S3 region (default: "us-east-1")
52    /// - `AWS_BUCKET`: S3 bucket name
53    /// - `AWS_PUBLIC_URL`: Public base URL for generated file URLs (overrides `AWS_URL` for this purpose)
54    /// - `AWS_URL`: S3 API endpoint; also used as public URL base if `AWS_PUBLIC_URL` is not set
55    /// - `CDN_URL`: CDN base URL fronting the bucket (used by `cdn_url()`).
56    ///   Legacy aliases `AWS_CDN_URL`, `CF_CDN_URL`, and `BUNNY_CDN_URL` are still accepted
57    ///   with a deprecation warning. Also reads `CDN_PROVIDER` / `CDN_PURGE_TOKEN` /
58    ///   `CDN_PURGE_ZONE` for cache-invalidation configuration; see [`crate::cdn::Config`].
59    ///
60    /// # Example
61    ///
62    /// ```rust,ignore
63    /// use ferro_storage::{StorageConfig, Storage};
64    ///
65    /// let config = StorageConfig::from_env();
66    /// let storage = Storage::with_storage_config(config);
67    /// ```
68    pub fn from_env() -> Self {
69        let default = env::var("FILESYSTEM_DISK").unwrap_or_else(|_| "local".to_string());
70        let mut disks = HashMap::new();
71
72        // Local disk
73        let local_root =
74            env::var("FILESYSTEM_LOCAL_ROOT").unwrap_or_else(|_| "./storage".to_string());
75        let mut local_config = DiskConfig::local(&local_root);
76        if let Ok(url) = env::var("FILESYSTEM_LOCAL_URL") {
77            local_config = local_config.with_url(url);
78        }
79        disks.insert("local".to_string(), local_config);
80
81        // Public disk (for publicly accessible files)
82        let public_root =
83            env::var("FILESYSTEM_PUBLIC_ROOT").unwrap_or_else(|_| "./storage/public".to_string());
84        let public_url =
85            env::var("FILESYSTEM_PUBLIC_URL").unwrap_or_else(|_| "/storage".to_string());
86        let public_config = DiskConfig::local(&public_root).with_url(public_url);
87        disks.insert("public".to_string(), public_config);
88
89        // S3 disk (if configured)
90        #[cfg(feature = "s3")]
91        if let Ok(bucket) = env::var("AWS_BUCKET") {
92            let region = env::var("AWS_DEFAULT_REGION").unwrap_or_else(|_| "us-east-1".to_string());
93            let mut s3_config = DiskConfig {
94                driver: DiskDriver::S3,
95                root: None,
96                url: None,
97                cdn_url: None,
98                bucket: Some(bucket.clone()),
99                region: Some(region),
100            };
101            // Resolve public file URL base (used by Storage::url() to build asset URLs).
102            // Priority: AWS_PUBLIC_URL → computed from AWS_URL+bucket → AWS_URL bare.
103            // Auto-compute handles providers like DigitalOcean Spaces and Cloudflare R2
104            // where the public URL is {bucket}.{endpoint_host} but the API endpoint is
105            // just {endpoint_host}.
106            let public_url = if let Ok(explicit) = env::var("AWS_PUBLIC_URL") {
107                Some(explicit)
108            } else if let Ok(api_url) = env::var("AWS_URL") {
109                let host = api_url
110                    .trim_start_matches("https://")
111                    .trim_start_matches("http://");
112                let scheme = if api_url.starts_with("https://") {
113                    "https"
114                } else {
115                    "http"
116                };
117                Some(format!("{scheme}://{bucket}.{host}"))
118            } else {
119                None
120            };
121            s3_config.url = public_url;
122            // Read the unified CDN config (handles CDN_URL quartet + AWS_CDN_URL fallback + deprecation warn).
123            let cdn_config = crate::cdn::Config::from_env();
124            if let Some(cdn_url) = cdn_config.url {
125                s3_config = s3_config.with_cdn_url(cdn_url);
126            }
127            disks.insert("s3".to_string(), s3_config);
128        }
129
130        Self { default, disks }
131    }
132
133    /// Add a disk configuration.
134    pub fn disk(mut self, name: impl Into<String>, config: DiskConfig) -> Self {
135        self.disks.insert(name.into(), config);
136        self
137    }
138
139    /// Set the default disk.
140    pub fn default_disk(mut self, name: impl Into<String>) -> Self {
141        self.default = name.into();
142        self
143    }
144
145    /// Get the default disk name.
146    pub fn get_default(&self) -> &str {
147        &self.default
148    }
149
150    /// Get a disk configuration by name.
151    pub fn get_disk(&self, name: &str) -> Option<&DiskConfig> {
152        self.disks.get(name)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use serial_test::serial;
160
161    #[test]
162    fn test_storage_config_defaults() {
163        let config = StorageConfig::default();
164        assert_eq!(config.default, "local");
165        assert!(config.disks.contains_key("local"));
166    }
167
168    #[test]
169    fn test_storage_config_builder() {
170        let config = StorageConfig::new("s3")
171            .disk("local", DiskConfig::local("./storage"))
172            .disk("public", DiskConfig::local("./public").with_url("/files"));
173
174        assert_eq!(config.default, "s3");
175        assert!(config.disks.contains_key("local"));
176        assert!(config.disks.contains_key("public"));
177    }
178
179    #[test]
180    #[serial]
181    fn test_storage_config_from_env() {
182        // Test with default env (no env vars set)
183        let config = StorageConfig::from_env();
184        assert_eq!(config.default, "local");
185        assert!(config.disks.contains_key("local"));
186        assert!(config.disks.contains_key("public"));
187    }
188
189    #[cfg(feature = "s3")]
190    #[test]
191    #[serial]
192    fn from_env_cdn_url() {
193        std::env::set_var("AWS_BUCKET", "test-bucket");
194        std::env::set_var("AWS_CDN_URL", "https://cdn.test.example.com");
195        let config = StorageConfig::from_env();
196        let s3_disk = config.get_disk("s3").expect("s3 disk should be configured");
197        assert_eq!(
198            s3_disk.cdn_url,
199            Some("https://cdn.test.example.com".to_string())
200        );
201        std::env::remove_var("AWS_BUCKET");
202        std::env::remove_var("AWS_CDN_URL");
203    }
204
205    /// SC-3: AWS_CDN_URL-only env → Disk::cdn_url bytes identical to the pre-phase direct read.
206    /// The fallback path in cdn::Config::from_env() must yield the same URL.
207    #[cfg(feature = "s3")]
208    #[test]
209    #[serial]
210    fn cdn_url_parity_aws_fallback() {
211        std::env::remove_var("CDN_URL");
212        std::env::set_var("AWS_BUCKET", "test-bucket");
213        std::env::set_var("AWS_CDN_URL", "https://cdn.parity.example.com");
214        let config = StorageConfig::from_env();
215        let s3_disk = config.get_disk("s3").expect("s3 disk should be configured");
216        assert_eq!(
217            s3_disk.cdn_url,
218            Some("https://cdn.parity.example.com".to_string()),
219            "AWS_CDN_URL fallback must yield byte-identical URL (SC-3 parity)"
220        );
221        std::env::remove_var("AWS_BUCKET");
222        std::env::remove_var("AWS_CDN_URL");
223    }
224
225    /// SC-4: legacy DO vars (DO_SPACES_CDN_ID + DIGITALOCEAN_ACCESS_TOKEN) → DoSpacesCdn purge
226    /// hits the same DO Spaces CDN endpoint and auth as the pre-phase code (T-204-PURGE-PARITY).
227    #[tokio::test]
228    #[serial]
229    async fn purge_parity_legacy_do() {
230        use crate::cdn::PurgeApi;
231        use wiremock::matchers::{header, method, path_regex};
232        use wiremock::{Mock, MockServer, ResponseTemplate};
233
234        // Simulate a legacy-DO-only deployment — clear the quartet so inference kicks in.
235        std::env::remove_var("CDN_PROVIDER");
236        std::env::remove_var("CDN_PURGE_ZONE");
237        std::env::remove_var("CDN_PURGE_TOKEN");
238        std::env::set_var("DO_SPACES_CDN_ID", "legacy-id");
239        std::env::set_var("DIGITALOCEAN_ACCESS_TOKEN", "legacy-token");
240
241        let cfg = crate::cdn::Config::from_env();
242        // Provider inferred from DO_SPACES_CDN_ID; zone/token read from legacy fallbacks.
243        assert_eq!(cfg.provider, crate::cdn::CdnProvider::DigitalOcean);
244        assert_eq!(cfg.purge_zone.as_deref(), Some("legacy-id"));
245        assert_eq!(cfg.purge_token.as_deref(), Some("legacy-token"));
246
247        // Build the adapter config the unified path yields, point it at the mock server,
248        // and assert identical endpoint + auth to the pre-phase DO purge path.
249        let server = MockServer::start().await;
250        let do_cfg = crate::DoSpacesCdnConfig {
251            endpoint_id: cfg.purge_zone.clone(),
252            api_token: cfg.purge_token.clone().unwrap_or_default(),
253            api_base: Some(server.uri()),
254        };
255        Mock::given(method("DELETE"))
256            .and(path_regex(r"/v2/cdn/endpoints/legacy-id/cache"))
257            .and(header("Authorization", "Bearer legacy-token"))
258            .respond_with(ResponseTemplate::new(204))
259            .expect(1)
260            .mount(&server)
261            .await;
262        let purger = crate::DoSpacesCdn::new(do_cfg);
263        purger.purge(&["index.html".to_string()]).await.unwrap();
264
265        std::env::remove_var("DO_SPACES_CDN_ID");
266        std::env::remove_var("DIGITALOCEAN_ACCESS_TOKEN");
267    }
268}