1use crate::facade::DiskConfig;
4#[cfg(feature = "s3")]
5use crate::facade::DiskDriver;
6use std::collections::HashMap;
7use std::env;
8
9#[derive(Debug, Clone)]
11pub struct StorageConfig {
12 pub default: String,
14 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 pub fn new(default: impl Into<String>) -> Self {
33 Self {
34 default: default.into(),
35 disks: HashMap::new(),
36 }
37 }
38
39 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 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 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 #[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 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 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 pub fn disk(mut self, name: impl Into<String>, config: DiskConfig) -> Self {
135 self.disks.insert(name.into(), config);
136 self
137 }
138
139 pub fn default_disk(mut self, name: impl Into<String>) -> Self {
141 self.default = name.into();
142 self
143 }
144
145 pub fn get_default(&self) -> &str {
147 &self.default
148 }
149
150 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 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 #[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 #[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 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 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 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}