1#[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#[derive(Debug, Clone)]
13pub struct StorageConfig {
14 pub default: String,
16 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 pub fn new(default: impl Into<String>) -> Self {
35 Self {
36 default: default.into(),
37 disks: HashMap::new(),
38 }
39 }
40
41 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 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 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 #[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 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 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 pub fn disk(mut self, name: impl Into<String>, config: DiskConfig) -> Self {
140 self.disks.insert(name.into(), config);
141 self
142 }
143
144 pub fn default_disk(mut self, name: impl Into<String>) -> Self {
146 self.default = name.into();
147 self
148 }
149
150 pub fn get_default(&self) -> &str {
152 &self.default
153 }
154
155 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 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 #[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 #[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 #[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 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 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 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}