1use std::str::FromStr;
2
3use super::network::parse_network_url;
4use super::validation::optional_text;
5use super::{ConfigError, Result, variables};
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum MediaLibraryBackend {
10 #[default]
12 Local,
13 S3,
15}
16
17impl FromStr for MediaLibraryBackend {
18 type Err = ConfigError;
19
20 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
21 match value.to_ascii_lowercase().as_str() {
22 "local" => Ok(Self::Local),
23 "s3" => Ok(Self::S3),
24 _ => Err(ConfigError::validation(
25 "media_library.backend",
26 "must be either `local` or `s3`",
27 )),
28 }
29 }
30}
31
32impl MediaLibraryBackend {
33 pub const fn as_str(self) -> &'static str {
35 match self {
36 Self::Local => "local",
37 Self::S3 => "s3",
38 }
39 }
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
44pub struct MediaLibraryConfig {
45 pub backend: MediaLibraryBackend,
47 pub object_root: String,
49 pub s3: MediaLibraryS3Config,
51}
52
53#[derive(Clone, Default, PartialEq, Eq)]
55pub struct MediaLibraryS3Config {
56 pub endpoint: Option<String>,
58 pub bucket: String,
60 pub region: Option<String>,
62 pub allow_http: bool,
64 pub virtual_hosted_style_request: bool,
66}
67
68impl std::fmt::Debug for MediaLibraryS3Config {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("MediaLibraryS3Config")
71 .field(
72 "endpoint",
73 &self.endpoint.as_deref().map(super::validation::redacted),
74 )
75 .field("bucket", &self.bucket)
76 .field("region", &self.region)
77 .field("allow_http", &self.allow_http)
78 .field(
79 "virtual_hosted_style_request",
80 &self.virtual_hosted_style_request,
81 )
82 .finish()
83 }
84}
85
86impl MediaLibraryConfig {
87 pub fn local(object_root: impl Into<String>) -> Self {
89 Self {
90 backend: MediaLibraryBackend::Local,
91 object_root: object_root.into(),
92 s3: MediaLibraryS3Config::default(),
93 }
94 }
95
96 pub(crate) fn apply_environment(
97 &mut self,
98 value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
99 ) -> Result<()> {
100 if let Some(backend) = variables::parsed(
101 "MEDIA_LIBRARY_BACKEND",
102 value("MEDIA_LIBRARY_BACKEND")?,
103 "must be either `local` or `s3`",
104 )? {
105 self.backend = backend;
106 }
107 if let Some(object_root) = value("MEDIA_LIBRARY_OBJECT_ROOT")? {
108 self.object_root = object_root;
109 }
110 if let Some(endpoint) = value("MEDIA_LIBRARY_S3_ENDPOINT")? {
111 self.s3.endpoint = Some(endpoint);
112 }
113 if let Some(bucket) = value("MEDIA_LIBRARY_S3_BUCKET")? {
114 self.s3.bucket = bucket;
115 }
116 if let Some(region) = value("MEDIA_LIBRARY_S3_REGION")? {
117 self.s3.region = Some(region);
118 }
119 if let Some(allow_http) = variables::parsed(
120 "MEDIA_LIBRARY_S3_ALLOW_HTTP",
121 value("MEDIA_LIBRARY_S3_ALLOW_HTTP")?,
122 "must be either `true` or `false`",
123 )? {
124 self.s3.allow_http = allow_http;
125 }
126 if let Some(virtual_hosted_style_request) = variables::parsed(
127 "MEDIA_LIBRARY_S3_VIRTUAL_HOSTED_STYLE_REQUEST",
128 value("MEDIA_LIBRARY_S3_VIRTUAL_HOSTED_STYLE_REQUEST")?,
129 "must be either `true` or `false`",
130 )? {
131 self.s3.virtual_hosted_style_request = virtual_hosted_style_request;
132 }
133 Ok(())
134 }
135
136 pub fn is_configured(&self) -> bool {
138 !self.object_root.is_empty()
139 || self.backend == MediaLibraryBackend::S3
140 || self.s3 != MediaLibraryS3Config::default()
141 }
142
143 pub fn validate(&self) -> Result<()> {
145 if !self.is_configured() {
146 return Ok(());
147 }
148
149 if !self.object_root.is_empty() {
150 validate_object_root(&self.object_root)?;
151 }
152 optional_text("media_library.s3.endpoint", self.s3.endpoint.as_deref())?;
153 optional_text("media_library.s3.region", self.s3.region.as_deref())?;
154 if let Some(endpoint) = &self.s3.endpoint {
155 let schemes: &[&str] = if self.s3.allow_http {
156 &["http", "https"]
157 } else {
158 &["https"]
159 };
160 let message = if self.s3.allow_http {
161 "must be an HTTP or HTTPS URL with a host and no embedded credentials"
162 } else {
163 "must be an HTTPS URL with a host and no embedded credentials"
164 };
165 parse_network_url(
166 "media_library.s3.endpoint",
167 endpoint,
168 schemes,
169 false,
170 message,
171 )?;
172 }
173
174 match self.backend {
175 MediaLibraryBackend::Local if self.object_root.trim().is_empty() => Err(
176 ConfigError::validation("media_library.object_root", "must not be empty"),
177 ),
178 MediaLibraryBackend::Local => Ok(()),
179 MediaLibraryBackend::S3 => {
180 if self.s3.bucket.trim().is_empty() {
181 return Err(ConfigError::validation(
182 "media_library.s3.bucket",
183 "must not be empty for the S3 backend",
184 ));
185 }
186 Ok(())
187 }
188 }
189 }
190}
191
192fn validate_object_root(value: &str) -> Result<()> {
193 let root = value.trim();
194 let has_windows_prefix = root.as_bytes().get(1) == Some(&b':')
195 && root.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
196 let invalid_component = root
197 .split('/')
198 .any(|component| component.is_empty() || component == "." || component == "..");
199
200 if root.is_empty()
201 || root.starts_with(['/', '\\'])
202 || has_windows_prefix
203 || root.contains('\\')
204 || root.contains('\0')
205 || invalid_component
206 {
207 return Err(ConfigError::validation(
208 "media_library.object_root",
209 "must be a safe relative logical prefix using forward-slash separators",
210 ));
211 }
212
213 Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 fn local_config(object_root: &str) -> MediaLibraryConfig {
221 MediaLibraryConfig {
222 backend: MediaLibraryBackend::Local,
223 object_root: object_root.to_owned(),
224 s3: MediaLibraryS3Config::default(),
225 }
226 }
227
228 #[test]
229 fn object_root_accepts_portable_relative_prefixes() {
230 for valid in ["objects", "objects/custom", "tenant 1/assets"] {
231 local_config(valid)
232 .validate()
233 .unwrap_or_else(|error| panic!("{valid} should be valid: {error}"));
234 }
235 }
236
237 #[test]
238 fn object_root_rejects_unix_absolute_and_parent_forms() {
239 for invalid in [
240 "/var/lib/objects",
241 "../objects",
242 "objects/../secret",
243 "./objects",
244 "objects//nested",
245 ] {
246 assert!(
247 local_config(invalid).validate().is_err(),
248 "{invalid} should be rejected"
249 );
250 }
251 }
252
253 #[test]
254 fn object_root_rejects_windows_absolute_prefix_and_parent_forms() {
255 for invalid in [
256 r"C:\objects",
257 "C:/objects",
258 r"\\server\share\objects",
259 r"\objects",
260 r"objects\..\secret",
261 r"objects\nested",
262 ] {
263 assert!(
264 local_config(invalid).validate().is_err(),
265 "{invalid} should be rejected"
266 );
267 }
268 }
269
270 #[test]
271 fn s3_endpoint_enforces_transport_host_and_credentials() {
272 let mut config = MediaLibraryConfig {
273 backend: MediaLibraryBackend::S3,
274 object_root: String::new(),
275 s3: MediaLibraryS3Config {
276 bucket: "assets".to_owned(),
277 ..MediaLibraryS3Config::default()
278 },
279 };
280
281 for invalid in [
282 "http://objects.example.invalid",
283 "ftp://objects.example.invalid",
284 "https:///missing-host",
285 "https://user:secret@objects.example.invalid",
286 ] {
287 config.s3.endpoint = Some(invalid.to_owned());
288 assert!(config.validate().is_err(), "{invalid} should be rejected");
289 }
290
291 config.s3.endpoint = Some("https://objects.example.invalid".to_owned());
292 config
293 .validate()
294 .expect("HTTPS endpoint should be accepted");
295
296 config.s3.allow_http = true;
297 config.s3.endpoint = Some("http://127.0.0.1:9000".to_owned());
298 config
299 .validate()
300 .expect("explicitly permitted local HTTP endpoint should be accepted");
301 }
302}