Skip to main content

ferro_storage/
config.rs

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