Skip to main content

ch_config/config/
media.rs

1use std::str::FromStr;
2
3use super::network::parse_network_url;
4use super::validation::optional_text;
5use super::{ConfigError, Result, variables};
6
7/// Durable media storage backend.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum MediaLibraryBackend {
10    /// Store objects on the local filesystem.
11    #[default]
12    Local,
13    /// Store objects in an S3-compatible service.
14    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    /// Stable configuration spelling.
34    pub const fn as_str(self) -> &'static str {
35        match self {
36            Self::Local => "local",
37            Self::S3 => "s3",
38        }
39    }
40}
41
42/// Durable media object storage settings.
43#[derive(Clone, Debug, Default, PartialEq, Eq)]
44pub struct MediaLibraryConfig {
45    /// Selected storage backend.
46    pub backend: MediaLibraryBackend,
47    /// Filesystem-relative object root for local storage.
48    pub object_root: String,
49    /// S3-specific settings.
50    pub s3: MediaLibraryS3Config,
51}
52
53/// S3-compatible object storage settings.
54#[derive(Clone, Default, PartialEq, Eq)]
55pub struct MediaLibraryS3Config {
56    /// Optional custom endpoint.
57    pub endpoint: Option<String>,
58    /// Bucket name.
59    pub bucket: String,
60    /// Optional provider region.
61    pub region: Option<String>,
62    /// Whether plain HTTP is explicitly permitted.
63    pub allow_http: bool,
64    /// Whether virtual-hosted-style requests are enabled.
65    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    /// Creates local durable object-storage defaults.
88    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    /// Reports whether media storage settings are present.
137    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    /// Validates the selected storage backend.
144    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}