use std::str::FromStr;
use super::network::parse_network_url;
use super::validation::optional_text;
use super::{ConfigError, Result, variables};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MediaLibraryBackend {
#[default]
Local,
S3,
}
impl FromStr for MediaLibraryBackend {
type Err = ();
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"local" => Ok(Self::Local),
"s3" => Ok(Self::S3),
_ => Err(()),
}
}
}
impl MediaLibraryBackend {
pub const fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
Self::S3 => "s3",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaLibraryConfig {
pub backend: MediaLibraryBackend,
pub object_root: String,
pub s3: MediaLibraryS3Config,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaLibraryS3Config {
pub endpoint: Option<String>,
pub bucket: String,
pub region: Option<String>,
pub allow_http: bool,
pub virtual_hosted_style_request: bool,
}
impl MediaLibraryConfig {
pub fn local(object_root: impl Into<String>) -> Self {
Self {
backend: MediaLibraryBackend::Local,
object_root: object_root.into(),
s3: MediaLibraryS3Config::default(),
}
}
pub(crate) fn apply_environment(
&mut self,
value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
) -> Result<()> {
if let Some(backend) = variables::parsed(
"MEDIA_LIBRARY_BACKEND",
value("MEDIA_LIBRARY_BACKEND")?,
"must be either `local` or `s3`",
)? {
self.backend = backend;
}
if let Some(object_root) = value("MEDIA_LIBRARY_OBJECT_ROOT")? {
self.object_root = object_root;
}
if let Some(endpoint) = value("MEDIA_LIBRARY_S3_ENDPOINT")? {
self.s3.endpoint = Some(endpoint);
}
if let Some(bucket) = value("MEDIA_LIBRARY_S3_BUCKET")? {
self.s3.bucket = bucket;
}
if let Some(region) = value("MEDIA_LIBRARY_S3_REGION")? {
self.s3.region = Some(region);
}
if let Some(allow_http) = variables::parsed(
"MEDIA_LIBRARY_S3_ALLOW_HTTP",
value("MEDIA_LIBRARY_S3_ALLOW_HTTP")?,
"must be either `true` or `false`",
)? {
self.s3.allow_http = allow_http;
}
if let Some(virtual_hosted_style_request) = variables::parsed(
"MEDIA_LIBRARY_S3_VIRTUAL_HOSTED_STYLE_REQUEST",
value("MEDIA_LIBRARY_S3_VIRTUAL_HOSTED_STYLE_REQUEST")?,
"must be either `true` or `false`",
)? {
self.s3.virtual_hosted_style_request = virtual_hosted_style_request;
}
Ok(())
}
pub fn is_configured(&self) -> bool {
!self.object_root.is_empty()
|| self.backend == MediaLibraryBackend::S3
|| self.s3 != MediaLibraryS3Config::default()
}
pub fn validate(&self) -> Result<()> {
if !self.is_configured() {
return Ok(());
}
if !self.object_root.is_empty() {
validate_object_root(&self.object_root)?;
}
optional_text("media_library.s3.endpoint", self.s3.endpoint.as_deref())?;
optional_text("media_library.s3.region", self.s3.region.as_deref())?;
if let Some(endpoint) = &self.s3.endpoint {
let schemes: &[&str] = if self.s3.allow_http {
&["http", "https"]
} else {
&["https"]
};
let message = if self.s3.allow_http {
"must be an HTTP or HTTPS URL with a host and no embedded credentials"
} else {
"must be an HTTPS URL with a host and no embedded credentials"
};
parse_network_url(
"media_library.s3.endpoint",
endpoint,
schemes,
false,
message,
)?;
}
match self.backend {
MediaLibraryBackend::Local if self.object_root.trim().is_empty() => Err(
ConfigError::validation("media_library.object_root", "must not be empty"),
),
MediaLibraryBackend::Local => Ok(()),
MediaLibraryBackend::S3 => {
if self.s3.bucket.trim().is_empty() {
return Err(ConfigError::validation(
"media_library.s3.bucket",
"must not be empty for the S3 backend",
));
}
Ok(())
}
}
}
}
fn validate_object_root(value: &str) -> Result<()> {
let root = value.trim();
let has_windows_prefix = root.as_bytes().get(1) == Some(&b':')
&& root.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
let invalid_component = root
.split('/')
.any(|component| component.is_empty() || component == "." || component == "..");
if root.is_empty()
|| root.starts_with(['/', '\\'])
|| has_windows_prefix
|| root.contains('\\')
|| root.contains('\0')
|| invalid_component
{
return Err(ConfigError::validation(
"media_library.object_root",
"must be a safe relative logical prefix using forward-slash separators",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn local_config(object_root: &str) -> MediaLibraryConfig {
MediaLibraryConfig {
backend: MediaLibraryBackend::Local,
object_root: object_root.to_owned(),
s3: MediaLibraryS3Config::default(),
}
}
#[test]
fn object_root_accepts_portable_relative_prefixes() {
for valid in ["objects", "objects/custom", "tenant 1/assets"] {
local_config(valid)
.validate()
.unwrap_or_else(|error| panic!("{valid} should be valid: {error}"));
}
}
#[test]
fn object_root_rejects_unix_absolute_and_parent_forms() {
for invalid in [
"/var/lib/objects",
"../objects",
"objects/../secret",
"./objects",
"objects//nested",
] {
assert!(
local_config(invalid).validate().is_err(),
"{invalid} should be rejected"
);
}
}
#[test]
fn object_root_rejects_windows_absolute_prefix_and_parent_forms() {
for invalid in [
r"C:\objects",
"C:/objects",
r"\\server\share\objects",
r"\objects",
r"objects\..\secret",
r"objects\nested",
] {
assert!(
local_config(invalid).validate().is_err(),
"{invalid} should be rejected"
);
}
}
#[test]
fn s3_endpoint_enforces_transport_host_and_credentials() {
let mut config = MediaLibraryConfig {
backend: MediaLibraryBackend::S3,
object_root: String::new(),
s3: MediaLibraryS3Config {
bucket: "assets".to_owned(),
..MediaLibraryS3Config::default()
},
};
for invalid in [
"http://objects.example.invalid",
"ftp://objects.example.invalid",
"https:///missing-host",
"https://user:secret@objects.example.invalid",
] {
config.s3.endpoint = Some(invalid.to_owned());
assert!(config.validate().is_err(), "{invalid} should be rejected");
}
config.s3.endpoint = Some("https://objects.example.invalid".to_owned());
config
.validate()
.expect("HTTPS endpoint should be accepted");
config.s3.allow_http = true;
config.s3.endpoint = Some("http://127.0.0.1:9000".to_owned());
config
.validate()
.expect("explicitly permitted local HTTP endpoint should be accepted");
}
}