use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BucketConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
pub storage: StorageConfig,
#[serde(default)]
pub serving: ServingConfig,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigEnvError {
#[error("environment variable `{0}` is required but not set")]
Missing(&'static str),
#[error("environment variable `{name}` has invalid value `{value}`: {source}")]
Invalid {
name: &'static str,
value: String,
#[source]
source: anyhow::Error,
},
}
impl Default for BucketConfig {
fn default() -> Self {
Self {
enabled: true,
storage: StorageConfig::Local {
root: PathBuf::from("/tmp/bucket"),
base_url: default_local_base_url(),
signing_secret_env: default_local_secret_env(),
},
serving: ServingConfig::default(),
}
}
}
impl BucketConfig {
pub fn from_env() -> Result<Self, ConfigEnvError> {
let backend = env_or("BUCKET_STORAGE_BACKEND", "local");
let storage = match backend.to_ascii_lowercase().as_str() {
"local" => StorageConfig::Local {
root: PathBuf::from(env_or("BUCKET_STORAGE_ROOT", "/tmp/bucket")),
base_url: env_url("BUCKET_BASE_URL", "http://localhost:8080/cdn/")?,
signing_secret_env: env_or(
"BUCKET_SIGNING_SECRET_ENV",
"BUCKET_LOCAL_SIGNING_SECRET",
),
},
"s3" => StorageConfig::S3(S3Config {
endpoint: env_url_required("BUCKET_S3_ENDPOINT")?,
region: env_required("BUCKET_S3_REGION")?,
access_key_env: env_or("BUCKET_S3_ACCESS_KEY_ENV", "BUCKET_S3_ACCESS_KEY"),
secret_key_env: env_or("BUCKET_S3_SECRET_KEY_ENV", "BUCKET_S3_SECRET_KEY"),
private_bucket: env_required("BUCKET_S3_PRIVATE_BUCKET")?,
public_bucket: std::env::var("BUCKET_S3_PUBLIC_BUCKET").ok(),
public_endpoint: match std::env::var("BUCKET_S3_PUBLIC_ENDPOINT") {
Ok(s) => Some(parse_url("BUCKET_S3_PUBLIC_ENDPOINT", &s)?),
Err(_) => None,
},
force_path_style: env_bool("BUCKET_S3_FORCE_PATH_STYLE", true)?,
}),
other => {
return Err(ConfigEnvError::Invalid {
name: "BUCKET_STORAGE_BACKEND",
value: other.to_string(),
source: anyhow::anyhow!("expected `local` or `s3`"),
})
}
};
let serving = ServingConfig {
default_mode: parse_serving_mode(&env_or("BUCKET_SERVING_MODE", "redirect"))?,
public_prefix: env_or("BUCKET_PUBLIC_PREFIX", "public/"),
presigned_ttl: Duration::from_secs(env_u64("BUCKET_PRESIGNED_TTL_SECS", 900)?),
};
Ok(Self {
enabled: env_bool("BUCKET_ENABLED", true)?,
storage,
serving,
})
}
}
fn env_or(name: &'static str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.to_string())
}
fn env_required(name: &'static str) -> Result<String, ConfigEnvError> {
std::env::var(name).map_err(|_| ConfigEnvError::Missing(name))
}
fn env_url(name: &'static str, default: &str) -> Result<Url, ConfigEnvError> {
let raw = env_or(name, default);
parse_url(name, &raw)
}
fn env_url_required(name: &'static str) -> Result<Url, ConfigEnvError> {
let raw = env_required(name)?;
parse_url(name, &raw)
}
fn parse_url(name: &'static str, raw: &str) -> Result<Url, ConfigEnvError> {
Url::parse(raw).map_err(|e| ConfigEnvError::Invalid {
name,
value: raw.to_string(),
source: anyhow::Error::from(e),
})
}
fn env_u64(name: &'static str, default: u64) -> Result<u64, ConfigEnvError> {
match std::env::var(name) {
Ok(s) => s.parse::<u64>().map_err(|e| ConfigEnvError::Invalid {
name,
value: s,
source: anyhow::Error::from(e),
}),
Err(_) => Ok(default),
}
}
fn env_bool(name: &'static str, default: bool) -> Result<bool, ConfigEnvError> {
match std::env::var(name) {
Ok(s) => match s.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(ConfigEnvError::Invalid {
name,
value: s,
source: anyhow::anyhow!("expected boolean (true/false/1/0/yes/no)"),
}),
},
Err(_) => Ok(default),
}
}
fn parse_serving_mode(s: &str) -> Result<ServingMode, ConfigEnvError> {
match s.to_ascii_lowercase().as_str() {
"redirect" => Ok(ServingMode::Redirect),
"stream" => Ok(ServingMode::Stream),
"signed_url" | "signedurl" => Ok(ServingMode::SignedUrl),
other => Err(ConfigEnvError::Invalid {
name: "BUCKET_SERVING_MODE",
value: other.to_string(),
source: anyhow::anyhow!("expected `redirect`, `stream`, or `signed_url`"),
}),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum StorageConfig {
Local {
root: PathBuf,
#[serde(default = "default_local_base_url")]
base_url: Url,
#[serde(default = "default_local_secret_env")]
signing_secret_env: String,
},
S3(S3Config),
}
fn default_local_base_url() -> Url {
Url::parse("http://localhost:8080/cdn/").expect("valid default base url")
}
fn default_local_secret_env() -> String {
"BUCKET_LOCAL_SIGNING_SECRET".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct S3Config {
pub endpoint: Url,
pub region: String,
pub access_key_env: String,
pub secret_key_env: String,
pub private_bucket: String,
#[serde(default)]
pub public_bucket: Option<String>,
#[serde(default)]
pub public_endpoint: Option<Url>,
#[serde(default = "default_force_path_style")]
pub force_path_style: bool,
}
fn default_force_path_style() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServingConfig {
#[serde(default = "default_serving_mode")]
pub default_mode: ServingMode,
#[serde(default = "default_public_prefix")]
pub public_prefix: String,
#[serde(default = "default_presigned_ttl", with = "duration_secs")]
pub presigned_ttl: Duration,
}
impl Default for ServingConfig {
fn default() -> Self {
Self {
default_mode: default_serving_mode(),
public_prefix: default_public_prefix(),
presigned_ttl: default_presigned_ttl(),
}
}
}
fn default_serving_mode() -> ServingMode {
ServingMode::Redirect
}
fn default_public_prefix() -> String {
"public/".to_string()
}
fn default_presigned_ttl() -> Duration {
Duration::from_secs(60 * 15)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServingMode {
Redirect,
Stream,
SignedUrl,
}
mod duration_secs {
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(d.as_secs())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let secs = u64::deserialize(d)?;
Ok(Duration::from_secs(secs))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_round_trip_and_defaults() {
for v in [
"BUCKET_STORAGE_BACKEND",
"BUCKET_STORAGE_ROOT",
"BUCKET_BASE_URL",
"BUCKET_SERVING_MODE",
"BUCKET_PUBLIC_PREFIX",
"BUCKET_PRESIGNED_TTL_SECS",
"BUCKET_S3_ENDPOINT",
"BUCKET_S3_REGION",
"BUCKET_S3_PRIVATE_BUCKET",
"BUCKET_S3_FORCE_PATH_STYLE",
] {
std::env::remove_var(v);
}
let cfg = BucketConfig::from_env().expect("defaults must work");
assert!(cfg.enabled);
match cfg.storage {
StorageConfig::Local { ref root, .. } => {
assert_eq!(root, std::path::Path::new("/tmp/bucket"))
}
_ => panic!("expected Local backend by default"),
}
assert_eq!(cfg.serving.default_mode, ServingMode::Redirect);
assert_eq!(cfg.serving.public_prefix, "public/");
assert_eq!(cfg.serving.presigned_ttl, Duration::from_secs(900));
std::env::set_var("BUCKET_STORAGE_BACKEND", "s3");
std::env::set_var("BUCKET_S3_ENDPOINT", "https://minio.example/");
std::env::set_var("BUCKET_S3_REGION", "us-east-1");
std::env::set_var("BUCKET_S3_PRIVATE_BUCKET", "private");
std::env::set_var("BUCKET_S3_FORCE_PATH_STYLE", "false");
std::env::set_var("BUCKET_SERVING_MODE", "signed_url");
std::env::set_var("BUCKET_PRESIGNED_TTL_SECS", "60");
let cfg = BucketConfig::from_env().expect("s3 config must parse");
match cfg.storage {
StorageConfig::S3(ref s3) => {
assert_eq!(s3.endpoint.as_str(), "https://minio.example/");
assert_eq!(s3.region, "us-east-1");
assert_eq!(s3.private_bucket, "private");
assert!(!s3.force_path_style);
}
_ => panic!("expected S3 backend"),
}
assert_eq!(cfg.serving.default_mode, ServingMode::SignedUrl);
assert_eq!(cfg.serving.presigned_ttl, Duration::from_secs(60));
std::env::remove_var("BUCKET_S3_PRIVATE_BUCKET");
let err = BucketConfig::from_env().unwrap_err();
assert!(matches!(err, ConfigEnvError::Missing("BUCKET_S3_PRIVATE_BUCKET")));
std::env::set_var("BUCKET_STORAGE_BACKEND", "azure");
let err = BucketConfig::from_env().unwrap_err();
assert!(matches!(err, ConfigEnvError::Invalid { name: "BUCKET_STORAGE_BACKEND", .. }));
for v in [
"BUCKET_STORAGE_BACKEND",
"BUCKET_S3_ENDPOINT",
"BUCKET_S3_REGION",
"BUCKET_S3_PRIVATE_BUCKET",
"BUCKET_S3_FORCE_PATH_STYLE",
"BUCKET_SERVING_MODE",
"BUCKET_PRESIGNED_TTL_SECS",
] {
std::env::remove_var(v);
}
}
#[test]
fn default_impl_matches_local_dev_shape() {
let cfg = BucketConfig::default();
assert!(cfg.enabled);
assert!(matches!(cfg.storage, StorageConfig::Local { .. }));
assert_eq!(cfg.serving.default_mode, ServingMode::Redirect);
}
}