diff --git a/Cargo.lock b/Cargo.lock index 378715aa..1370ca03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,6 +1153,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "config" +version = "0.1.0" +dependencies = [ + "figment", + "serde", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1934,6 +1944,22 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml", + "uncased", + "version_check", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -2749,6 +2775,12 @@ dependencies = [ "sqlx", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "inout" version = "0.1.4" @@ -3668,6 +3700,29 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + [[package]] name = "pem" version = "3.0.6" @@ -4029,6 +4084,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + [[package]] name = "profiling" version = "1.0.18" @@ -5934,6 +6002,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime 0.6.11", + "toml_write", "winnow 0.7.15", ] @@ -5958,6 +6027,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -6142,6 +6217,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicase" version = "2.9.0" @@ -7160,6 +7244,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index e38ee522..7bbd3f0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/adapters/jellyfin", "crates/adapters/plex", "crates/infra-wiring", + "crates/config", ] resolver = "2" @@ -63,6 +64,7 @@ hex = "0.4" object_store = { version = "0.11", features = ["aws"] } axum = { version = "0.8.8", features = ["macros", "multipart"] } csv = "1" +figment = { version = "0.10", features = ["toml", "env"] } api-types = { path = "crates/api-types" } domain = { path = "crates/domain" } @@ -89,6 +91,7 @@ jellyfin = { path = "crates/adapters/jellyfin" } plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } infra-wiring = { path = "crates/infra-wiring" } +config = { path = "crates/config" } [profile.dev] debug = 1 # line tables only — still debuggable, much faster linking diff --git a/Dockerfile b/Dockerfile index 1b4c67bd..a737d7be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,7 @@ COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml +COPY crates/config/Cargo.toml crates/config/Cargo.toml # Stub every crate so cargo can resolve and fetch deps RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \ diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml new file mode 100644 index 00000000..01a3c64b --- /dev/null +++ b/crates/config/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "config" +edition.workspace = true +version = "0.1.0" + +[dependencies] +thiserror.workspace = true +serde.workspace = true +figment.workspace = true +tracing.workspace = true + +[dev-dependencies] +figment = { workspace = true, features = ["test"] } diff --git a/crates/config/src/app_config.rs b/crates/config/src/app_config.rs new file mode 100644 index 00000000..cf291739 --- /dev/null +++ b/crates/config/src/app_config.rs @@ -0,0 +1,29 @@ +use serde::Deserialize; + +use crate::{ + AuthConfig, DatabaseConfig, EventsConfig, FederationConfig, ImageConversionConfig, + MetadataConfig, NatsConfig, ServerConfig, StorageConfig, WrapUpConfig, +}; + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub server: ServerConfig, + pub database: DatabaseConfig, + #[serde(default)] + pub auth: AuthConfig, + #[serde(default)] + pub events: EventsConfig, + #[serde(default)] + pub nats: NatsConfig, + #[serde(default)] + pub storage: StorageConfig, + #[serde(default)] + pub image_conversion: ImageConversionConfig, + #[serde(default)] + pub metadata: MetadataConfig, + #[serde(default)] + pub federation: FederationConfig, + #[serde(default)] + pub wrapup: WrapUpConfig, +} diff --git a/crates/config/src/auth_config.rs b/crates/config/src/auth_config.rs new file mode 100644 index 00000000..0005948a --- /dev/null +++ b/crates/config/src/auth_config.rs @@ -0,0 +1,38 @@ +use std::fmt; + +use serde::Deserialize; + +use crate::lenient; + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub struct AuthConfig { + pub jwt_secret: Option, + #[serde(deserialize_with = "lenient::number")] + pub jwt_ttl_seconds: u64, + #[serde(deserialize_with = "lenient::number")] + pub refresh_ttl_seconds: u64, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + jwt_secret: None, + jwt_ttl_seconds: 900, + refresh_ttl_seconds: 2_592_000, + } + } +} + +impl fmt::Debug for AuthConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuthConfig") + .field( + "jwt_secret", + &self.jwt_secret.as_ref().map(|_| ""), + ) + .field("jwt_ttl_seconds", &self.jwt_ttl_seconds) + .field("refresh_ttl_seconds", &self.refresh_ttl_seconds) + .finish() + } +} diff --git a/crates/config/src/database_config.rs b/crates/config/src/database_config.rs new file mode 100644 index 00000000..1f301f12 --- /dev/null +++ b/crates/config/src/database_config.rs @@ -0,0 +1,16 @@ +use std::fmt; + +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, +} + +impl fmt::Debug for DatabaseConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DatabaseConfig") + .field("url", &"") + .finish() + } +} diff --git a/crates/config/src/errors.rs b/crates/config/src/errors.rs new file mode 100644 index 00000000..5d8577e1 --- /dev/null +++ b/crates/config/src/errors.rs @@ -0,0 +1,11 @@ +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("configuration file {0} was named by CONFIG_FILE but does not exist")] + NamedFileMissing(String), + #[error("{0} is required but not set")] + Missing(&'static str), + #[error("invalid value for {variable}: {reason}")] + Invalid { variable: String, reason: String }, + #[error("could not read configuration: {0}")] + Unreadable(#[from] Box), +} diff --git a/crates/config/src/events_config.rs b/crates/config/src/events_config.rs new file mode 100644 index 00000000..d1ec745e --- /dev/null +++ b/crates/config/src/events_config.rs @@ -0,0 +1,29 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct EventsConfig { + pub bus_backend: String, + #[serde(deserialize_with = "lenient::number")] + pub channel_buffer: usize, + #[serde(deserialize_with = "lenient::number")] + pub queue_poll_interval_ms: u64, + #[serde(deserialize_with = "lenient::number")] + pub queue_batch_size: i64, + #[serde(deserialize_with = "lenient::number")] + pub queue_max_attempts: i32, +} + +impl Default for EventsConfig { + fn default() -> Self { + Self { + bus_backend: "db".into(), + channel_buffer: 128, + queue_poll_interval_ms: 500, + queue_batch_size: 10, + queue_max_attempts: 5, + } + } +} diff --git a/crates/config/src/federation_config.rs b/crates/config/src/federation_config.rs new file mode 100644 index 00000000..5e8df522 --- /dev/null +++ b/crates/config/src/federation_config.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct FederationConfig { + #[serde(deserialize_with = "lenient::true_or_one")] + pub debug: bool, +} diff --git a/crates/config/src/image_conversion_config.rs b/crates/config/src/image_conversion_config.rs new file mode 100644 index 00000000..066a0524 --- /dev/null +++ b/crates/config/src/image_conversion_config.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct ImageConversionConfig { + #[serde(deserialize_with = "lenient::true_only")] + pub enabled: bool, + pub format: Option, +} diff --git a/crates/config/src/lenient.rs b/crates/config/src/lenient.rs new file mode 100644 index 00000000..74377255 --- /dev/null +++ b/crates/config/src/lenient.rs @@ -0,0 +1,79 @@ +//! Deserializers for values that arrive as strings from the environment but +//! may be native numbers/bools in a TOML file. + +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; + +use serde::Deserializer; +use serde::de::{self, Visitor}; + +struct Number(PhantomData); + +impl Visitor<'_> for Number { + type Value = T; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a number") + } + + fn visit_str(self, v: &str) -> Result { + v.trim() + .parse() + .map_err(|_| E::custom("expected a number in range")) + } + + fn visit_u64(self, v: u64) -> Result { + self.visit_str(&v.to_string()) + } + + fn visit_i64(self, v: i64) -> Result { + self.visit_str(&v.to_string()) + } +} + +pub fn number<'de, D, T>(d: D) -> Result +where + D: Deserializer<'de>, + T: FromStr, +{ + d.deserialize_any(Number(PhantomData)) +} + +struct Flag { + accepts_one: bool, +} + +impl Visitor<'_> for Flag { + type Value = bool; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(v) + } + + fn visit_str(self, v: &str) -> Result { + Ok(v == "true" || (self.accepts_one && v == "1")) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(self.accepts_one && v == 1) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(self.accepts_one && v == 1) + } +} + +/// `true` only for `true` or `1` (anything else is false, like the old readers). +pub fn true_or_one<'de, D: Deserializer<'de>>(d: D) -> Result { + d.deserialize_any(Flag { accepts_one: true }) +} + +/// `true` only for `true`. +pub fn true_only<'de, D: Deserializer<'de>>(d: D) -> Result { + d.deserialize_any(Flag { accepts_one: false }) +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs new file mode 100644 index 00000000..4de68b35 --- /dev/null +++ b/crates/config/src/lib.rs @@ -0,0 +1,28 @@ +mod app_config; +mod auth_config; +mod database_config; +mod errors; +mod events_config; +mod federation_config; +mod image_conversion_config; +mod lenient; +mod loader; +mod metadata_config; +mod nats_config; +mod server_config; +mod storage_config; +mod wrapup_config; + +pub use app_config::AppConfig; +pub use auth_config::AuthConfig; +pub use database_config::DatabaseConfig; +pub use errors::ConfigError; +pub use events_config::EventsConfig; +pub use federation_config::FederationConfig; +pub use image_conversion_config::ImageConversionConfig; +pub use loader::load; +pub use metadata_config::MetadataConfig; +pub use nats_config::NatsConfig; +pub use server_config::ServerConfig; +pub use storage_config::StorageConfig; +pub use wrapup_config::WrapUpConfig; diff --git a/crates/config/src/loader.rs b/crates/config/src/loader.rs new file mode 100644 index 00000000..e0fc448b --- /dev/null +++ b/crates/config/src/loader.rs @@ -0,0 +1,139 @@ +use std::path::Path; + +use figment::providers::{Format, Toml}; +use figment::value::{Dict, Map, Value}; +use figment::{Figment, Metadata, Profile, Provider}; + +use crate::AppConfig; +use crate::errors::ConfigError; + +const DEFAULT_CONFIG_FILE: &str = "config.toml"; +const CONFIG_FILE_VARIABLE: &str = "CONFIG_FILE"; + +/// Every environment variable the application has always honoured, mapped to +/// its `section.field` key. No prefix: the names are the public contract. +const LEGACY_ENV: &[(&str, &str)] = &[ + ("ALLOW_REGISTRATION", "server.allow_registration"), + ("BASE_URL", "server.base_url"), + ("CORS_ORIGINS", "server.cors_origins"), + ("DATABASE_URL", "database.url"), + ("EVENT_BUS_BACKEND", "events.bus_backend"), + ("EVENT_CHANNEL_BUFFER", "events.channel_buffer"), + ("EVENT_QUEUE_BATCH_SIZE", "events.queue_batch_size"), + ("EVENT_QUEUE_MAX_ATTEMPTS", "events.queue_max_attempts"), + ( + "EVENT_QUEUE_POLL_INTERVAL_MS", + "events.queue_poll_interval_ms", + ), + ("FEDERATION_DEBUG", "federation.debug"), + ("HOST", "server.host"), + ("PORT", "server.port"), + ("IMAGE_CONVERSION_ENABLED", "image_conversion.enabled"), + ("IMAGE_CONVERSION_FORMAT", "image_conversion.format"), + ("IMAGE_STORAGE_BACKEND", "storage.backend"), + ("IMAGE_STORAGE_PATH", "storage.path"), + ("JWT_SECRET", "auth.jwt_secret"), + ("JWT_TTL_SECONDS", "auth.jwt_ttl_seconds"), + ("MINIO_ACCESS_KEY_ID", "storage.access_key_id"), + ("MINIO_BUCKET", "storage.bucket"), + ("MINIO_ENDPOINT", "storage.endpoint"), + ("MINIO_REGION", "storage.region"), + ("MINIO_SECRET_ACCESS_KEY", "storage.secret_access_key"), + ("NATS_CONSUMER_NAME", "nats.consumer_name"), + ("NATS_MODE", "nats.mode"), + ("NATS_STREAM_NAME", "nats.stream_name"), + ("NATS_SUBJECT_PREFIX", "nats.subject_prefix"), + ("NATS_URL", "nats.url"), + ("OMDB_API_KEY", "metadata.omdb_api_key"), + ( + "POSTER_FETCH_TIMEOUT_SECONDS", + "metadata.poster_fetch_timeout_seconds", + ), + ("RATE_LIMIT", "server.rate_limit"), + ("REFRESH_TTL_SECONDS", "auth.refresh_ttl_seconds"), + ("SECURE_COOKIES", "server.secure_cookies"), + ("TMDB_API_KEY", "metadata.tmdb_api_key"), + ("WRAPUP_BG_DIR", "wrapup.bg_dir"), + ("WRAPUP_FONT_PATH", "wrapup.font_path"), + ("WRAPUP_LOGO_PATH", "wrapup.logo_path"), +]; + +/// Reads the legacy variables as plain strings; typing is left to the config +/// structs so that e.g. `JWT_SECRET=12345` stays a string. +struct LegacyEnv; + +impl Provider for LegacyEnv { + fn metadata(&self) -> Metadata { + Metadata::named("environment variables") + } + + fn data(&self) -> Result, figment::Error> { + let mut sections: Dict = Dict::new(); + for (variable, key) in LEGACY_ENV { + let Some(raw) = std::env::var_os(variable) else { + continue; + }; + let value = raw + .into_string() + .map_err(|_| figment::Error::from(format!("{variable} is not valid unicode")))?; + let (section, field) = key.split_once('.').expect("legacy keys are section.field"); + let entry = sections + .entry(section.to_owned()) + .or_insert_with(|| Value::from(Dict::new())); + if let Value::Dict(_, dict) = entry { + dict.insert(field.to_owned(), Value::from(value)); + } + } + Ok(Profile::Default.collect(sections)) + } +} + +pub fn load() -> Result { + let named_file = std::env::var(CONFIG_FILE_VARIABLE).ok(); + let path = named_file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + + if named_file.is_some() && !Path::new(path).exists() { + return Err(ConfigError::NamedFileMissing(path.to_owned())); + } + + let figment = Figment::new().merge(Toml::file(path)).merge(LegacyEnv); + + if figment.find_value("database.url").is_err() { + return Err(ConfigError::Missing("DATABASE_URL")); + } + + let mut config: AppConfig = figment.extract().map_err(into_config_error)?; + config.server.base_url = config.server.base_url.trim_end_matches('/').to_owned(); + + announce(path); + + Ok(config) +} + +fn into_config_error(error: figment::Error) -> ConfigError { + let key = error.path.join("."); + match LEGACY_ENV.iter().find(|(_, k)| *k == key) { + Some((variable, _)) if std::env::var_os(variable).is_some() => ConfigError::Invalid { + variable: (*variable).to_owned(), + reason: error.kind.to_string(), + }, + _ => ConfigError::Unreadable(Box::new(error)), + } +} + +fn announce(path: &str) { + if Path::new(path).exists() { + tracing::info!(path, "loaded configuration from file"); + } else { + tracing::info!("no configuration file found, using defaults"); + } + + let overriding: Vec<&str> = LEGACY_ENV + .iter() + .map(|(variable, _)| *variable) + .filter(|variable| std::env::var_os(variable).is_some()) + .collect(); + if !overriding.is_empty() { + tracing::info!(variables = ?overriding, "configuration overridden from the environment"); + } +} diff --git a/crates/config/src/metadata_config.rs b/crates/config/src/metadata_config.rs new file mode 100644 index 00000000..b1d8589a --- /dev/null +++ b/crates/config/src/metadata_config.rs @@ -0,0 +1,43 @@ +use std::fmt; + +use serde::Deserialize; + +use crate::lenient; + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub struct MetadataConfig { + pub tmdb_api_key: Option, + pub omdb_api_key: Option, + #[serde(deserialize_with = "lenient::number")] + pub poster_fetch_timeout_seconds: u64, +} + +impl Default for MetadataConfig { + fn default() -> Self { + Self { + tmdb_api_key: None, + omdb_api_key: None, + poster_fetch_timeout_seconds: 30, + } + } +} + +impl fmt::Debug for MetadataConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MetadataConfig") + .field( + "tmdb_api_key", + &self.tmdb_api_key.as_ref().map(|_| ""), + ) + .field( + "omdb_api_key", + &self.omdb_api_key.as_ref().map(|_| ""), + ) + .field( + "poster_fetch_timeout_seconds", + &self.poster_fetch_timeout_seconds, + ) + .finish() + } +} diff --git a/crates/config/src/nats_config.rs b/crates/config/src/nats_config.rs new file mode 100644 index 00000000..cb878bb3 --- /dev/null +++ b/crates/config/src/nats_config.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct NatsConfig { + pub url: Option, + pub mode: String, + pub subject_prefix: String, + pub stream_name: String, + pub consumer_name: String, +} + +impl Default for NatsConfig { + fn default() -> Self { + Self { + url: None, + mode: "jetstream".into(), + subject_prefix: "movies-diary.events".into(), + stream_name: "MOVIES_DIARY_EVENTS".into(), + consumer_name: "worker".into(), + } + } +} diff --git a/crates/config/src/server_config.rs b/crates/config/src/server_config.rs new file mode 100644 index 00000000..140066ae --- /dev/null +++ b/crates/config/src/server_config.rs @@ -0,0 +1,33 @@ +use serde::Deserialize; + +use crate::lenient; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + pub host: String, + #[serde(deserialize_with = "lenient::number")] + pub port: u16, + pub base_url: String, + pub cors_origins: String, + #[serde(deserialize_with = "lenient::true_or_one")] + pub allow_registration: bool, + #[serde(deserialize_with = "lenient::true_only")] + pub secure_cookies: bool, + #[serde(deserialize_with = "lenient::number")] + pub rate_limit: u64, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + host: "0.0.0.0".into(), + port: 3000, + base_url: "http://localhost:3000".into(), + cors_origins: String::new(), + allow_registration: false, + secure_cookies: false, + rate_limit: 60, + } + } +} diff --git a/crates/config/src/storage_config.rs b/crates/config/src/storage_config.rs new file mode 100644 index 00000000..ef58f1d0 --- /dev/null +++ b/crates/config/src/storage_config.rs @@ -0,0 +1,41 @@ +use std::fmt; + +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub struct StorageConfig { + pub backend: String, + pub path: String, + pub endpoint: Option, + pub access_key_id: Option, + pub secret_access_key: Option, + pub bucket: Option, + pub region: String, +} + +impl Default for StorageConfig { + fn default() -> Self { + Self { + backend: "local".into(), + path: "./images".into(), + endpoint: None, + access_key_id: None, + secret_access_key: None, + bucket: None, + region: "minio".into(), + } + } +} + +impl fmt::Debug for StorageConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StorageConfig") + .field("backend", &self.backend) + .field("path", &self.path) + .field("endpoint", &self.endpoint) + .field("bucket", &self.bucket) + .field("region", &self.region) + .finish_non_exhaustive() + } +} diff --git a/crates/config/src/wrapup_config.rs b/crates/config/src/wrapup_config.rs new file mode 100644 index 00000000..89b896e7 --- /dev/null +++ b/crates/config/src/wrapup_config.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct WrapUpConfig { + pub font_path: Option, + pub logo_path: Option, + pub bg_dir: Option, +} diff --git a/crates/config/tests/loader_test.rs b/crates/config/tests/loader_test.rs new file mode 100644 index 00000000..88d984ad --- /dev/null +++ b/crates/config/tests/loader_test.rs @@ -0,0 +1,379 @@ +#![allow(clippy::result_large_err)] + +use config::{ConfigError, load}; +use figment::Jail; + +fn jail_with(jail: &mut Jail, vars: &[(&str, &str)]) { + jail.clear_env(); + jail.set_env("DATABASE_URL", "sqlite://test.db"); + for (name, value) in vars { + jail.set_env(name, value); + } +} + +macro_rules! env_sets { + ($test:ident, $var:literal, $value:literal, |$c:ident| $assert:expr) => { + #[test] + fn $test() { + Jail::expect_with(|jail| { + jail_with(jail, &[($var, $value)]); + let $c = load().expect("config loads"); + assert!($assert, "{} not applied", $var); + Ok(()) + }); + } + }; +} + +env_sets!( + allow_registration_env_sets_allow_registration, + "ALLOW_REGISTRATION", + "true", + |c| c.server.allow_registration +); +env_sets!( + base_url_env_sets_base_url, + "BASE_URL", + "https://x.example/", + |c| c.server.base_url == "https://x.example" +); +env_sets!( + cors_origins_env_sets_cors_origins, + "CORS_ORIGINS", + "https://a.example,https://b.example", + |c| c.server.cors_origins == "https://a.example,https://b.example" +); +env_sets!( + database_url_env_sets_url, + "DATABASE_URL", + "sqlite://other.db", + |c| c.database.url == "sqlite://other.db" +); +env_sets!( + event_bus_backend_env_sets_bus_backend, + "EVENT_BUS_BACKEND", + "nats", + |c| c.events.bus_backend == "nats" +); +env_sets!( + event_channel_buffer_env_sets_channel_buffer, + "EVENT_CHANNEL_BUFFER", + "7", + |c| c.events.channel_buffer == 7 +); +env_sets!( + event_queue_batch_size_env_sets_queue_batch_size, + "EVENT_QUEUE_BATCH_SIZE", + "11", + |c| c.events.queue_batch_size == 11 +); +env_sets!( + event_queue_max_attempts_env_sets_queue_max_attempts, + "EVENT_QUEUE_MAX_ATTEMPTS", + "9", + |c| c.events.queue_max_attempts == 9 +); +env_sets!( + event_queue_poll_interval_ms_env_sets_queue_poll_interval_ms, + "EVENT_QUEUE_POLL_INTERVAL_MS", + "250", + |c| c.events.queue_poll_interval_ms == 250 +); +env_sets!( + federation_debug_env_sets_debug, + "FEDERATION_DEBUG", + "1", + |c| c.federation.debug +); +env_sets!(host_env_sets_host, "HOST", "127.0.0.1", |c| c.server.host + == "127.0.0.1"); +env_sets!(port_env_sets_port, "PORT", "8080", |c| c.server.port + == 8080); +env_sets!( + image_conversion_enabled_env_sets_enabled, + "IMAGE_CONVERSION_ENABLED", + "true", + |c| c.image_conversion.enabled +); +env_sets!( + image_conversion_format_env_sets_format, + "IMAGE_CONVERSION_FORMAT", + "webp", + |c| c.image_conversion.format.as_deref() == Some("webp") +); +env_sets!( + image_storage_backend_env_sets_backend, + "IMAGE_STORAGE_BACKEND", + "s3", + |c| c.storage.backend == "s3" +); +env_sets!( + image_storage_path_env_sets_path, + "IMAGE_STORAGE_PATH", + "/data/img", + |c| c.storage.path == "/data/img" +); +env_sets!( + jwt_secret_env_sets_jwt_secret, + "JWT_SECRET", + "s3cret", + |c| c.auth.jwt_secret.as_deref() == Some("s3cret") +); +env_sets!( + jwt_ttl_seconds_env_sets_jwt_ttl_seconds, + "JWT_TTL_SECONDS", + "60", + |c| c.auth.jwt_ttl_seconds == 60 +); +env_sets!( + minio_access_key_id_env_sets_access_key_id, + "MINIO_ACCESS_KEY_ID", + "ak", + |c| c.storage.access_key_id.as_deref() == Some("ak") +); +env_sets!(minio_bucket_env_sets_bucket, "MINIO_BUCKET", "b", |c| c + .storage + .bucket + .as_deref() + == Some("b")); +env_sets!( + minio_endpoint_env_sets_endpoint, + "MINIO_ENDPOINT", + "http://minio:9000", + |c| c.storage.endpoint.as_deref() == Some("http://minio:9000") +); +env_sets!(minio_region_env_sets_region, "MINIO_REGION", "eu", |c| c + .storage + .region + == "eu"); +env_sets!( + minio_secret_access_key_env_sets_secret_access_key, + "MINIO_SECRET_ACCESS_KEY", + "sk", + |c| c.storage.secret_access_key.as_deref() == Some("sk") +); +env_sets!( + nats_consumer_name_env_sets_consumer_name, + "NATS_CONSUMER_NAME", + "w2", + |c| c.nats.consumer_name == "w2" +); +env_sets!(nats_mode_env_sets_mode, "NATS_MODE", "core", |c| c + .nats + .mode + == "core"); +env_sets!( + nats_stream_name_env_sets_stream_name, + "NATS_STREAM_NAME", + "S", + |c| c.nats.stream_name == "S" +); +env_sets!( + nats_subject_prefix_env_sets_subject_prefix, + "NATS_SUBJECT_PREFIX", + "p.e", + |c| c.nats.subject_prefix == "p.e" +); +env_sets!(nats_url_env_sets_url, "NATS_URL", "nats://n:4222", |c| c + .nats + .url + .as_deref() + == Some("nats://n:4222")); +env_sets!( + omdb_api_key_env_sets_omdb_api_key, + "OMDB_API_KEY", + "ok", + |c| c.metadata.omdb_api_key.as_deref() == Some("ok") +); +env_sets!( + poster_fetch_timeout_seconds_env_sets_poster_fetch_timeout_seconds, + "POSTER_FETCH_TIMEOUT_SECONDS", + "5", + |c| c.metadata.poster_fetch_timeout_seconds == 5 +); +env_sets!(rate_limit_env_sets_rate_limit, "RATE_LIMIT", "120", |c| c + .server + .rate_limit + == 120); +env_sets!( + refresh_ttl_seconds_env_sets_refresh_ttl_seconds, + "REFRESH_TTL_SECONDS", + "99", + |c| c.auth.refresh_ttl_seconds == 99 +); +env_sets!( + secure_cookies_env_sets_secure_cookies, + "SECURE_COOKIES", + "true", + |c| c.server.secure_cookies +); +env_sets!( + tmdb_api_key_env_sets_tmdb_api_key, + "TMDB_API_KEY", + "tk", + |c| c.metadata.tmdb_api_key.as_deref() == Some("tk") +); +env_sets!(wrapup_bg_dir_env_sets_bg_dir, "WRAPUP_BG_DIR", "/bg", |c| c + .wrapup + .bg_dir + .as_deref() + == Some("/bg")); +env_sets!( + wrapup_font_path_env_sets_font_path, + "WRAPUP_FONT_PATH", + "/f.ttf", + |c| c.wrapup.font_path.as_deref() == Some("/f.ttf") +); +env_sets!( + wrapup_logo_path_env_sets_logo_path, + "WRAPUP_LOGO_PATH", + "/l.png", + |c| c.wrapup.logo_path.as_deref() == Some("/l.png") +); + +#[test] +fn numeric_looking_string_values_stay_strings() { + Jail::expect_with(|jail| { + jail_with(jail, &[("JWT_SECRET", "12345"), ("TMDB_API_KEY", "1")]); + let c = load().expect("config loads"); + assert_eq!(c.auth.jwt_secret.as_deref(), Some("12345")); + assert_eq!(c.metadata.tmdb_api_key.as_deref(), Some("1")); + Ok(()) + }); +} + +#[test] +fn defaults_match_the_previous_env_readers() { + Jail::expect_with(|jail| { + jail_with(jail, &[]); + let c = load().expect("config loads"); + assert!(!c.server.allow_registration); + assert_eq!(c.server.base_url, "http://localhost:3000"); + assert_eq!(c.server.cors_origins, ""); + assert_eq!(c.server.host, "0.0.0.0"); + assert_eq!(c.server.port, 3000); + assert_eq!(c.server.rate_limit, 60); + assert!(!c.server.secure_cookies); + assert_eq!(c.database.url, "sqlite://test.db"); + assert_eq!(c.auth.jwt_secret, None); + assert_eq!(c.auth.jwt_ttl_seconds, 900); + assert_eq!(c.auth.refresh_ttl_seconds, 2_592_000); + assert_eq!(c.events.bus_backend, "db"); + assert_eq!(c.events.channel_buffer, 128); + assert_eq!(c.events.queue_poll_interval_ms, 500); + assert_eq!(c.events.queue_batch_size, 10); + assert_eq!(c.events.queue_max_attempts, 5); + assert_eq!(c.nats.url, None); + assert_eq!(c.nats.mode, "jetstream"); + assert_eq!(c.nats.subject_prefix, "movies-diary.events"); + assert_eq!(c.nats.stream_name, "MOVIES_DIARY_EVENTS"); + assert_eq!(c.nats.consumer_name, "worker"); + assert_eq!(c.storage.backend, "local"); + assert_eq!(c.storage.path, "./images"); + assert_eq!(c.storage.endpoint, None); + assert_eq!(c.storage.access_key_id, None); + assert_eq!(c.storage.secret_access_key, None); + assert_eq!(c.storage.bucket, None); + assert_eq!(c.storage.region, "minio"); + assert!(!c.image_conversion.enabled); + assert_eq!(c.image_conversion.format, None); + assert_eq!(c.metadata.tmdb_api_key, None); + assert_eq!(c.metadata.omdb_api_key, None); + assert_eq!(c.metadata.poster_fetch_timeout_seconds, 30); + assert!(!c.federation.debug); + assert_eq!(c.wrapup.font_path, None); + assert_eq!(c.wrapup.logo_path, None); + assert_eq!(c.wrapup.bg_dir, None); + Ok(()) + }); +} + +#[test] +fn toml_file_is_overridden_by_env() { + Jail::expect_with(|jail| { + jail_with(jail, &[("PORT", "9000")]); + jail.create_file( + "config.toml", + "[server]\nport = 1234\nhost = \"10.0.0.1\"\n[auth]\njwt_secret = \"from-file\"\n", + )?; + let c = load().expect("config loads"); + assert_eq!(c.server.port, 9000, "env wins over file"); + assert_eq!(c.server.host, "10.0.0.1", "file wins over default"); + assert_eq!(c.auth.jwt_secret.as_deref(), Some("from-file")); + Ok(()) + }); +} + +#[test] +fn config_file_env_names_an_alternative_file() { + Jail::expect_with(|jail| { + jail_with(jail, &[("CONFIG_FILE", "alt.toml")]); + jail.create_file("config.toml", "[server]\nport = 1\n")?; + jail.create_file("alt.toml", "[server]\nport = 2\n")?; + assert_eq!(load().expect("config loads").server.port, 2); + Ok(()) + }); +} + +#[test] +fn missing_named_config_file_is_an_error() { + Jail::expect_with(|jail| { + jail_with(jail, &[("CONFIG_FILE", "nope.toml")]); + let err = load().expect_err("named file is missing"); + assert!(matches!(err, ConfigError::NamedFileMissing(_)), "{err}"); + Ok(()) + }); +} + +#[test] +fn missing_database_url_is_an_error_naming_the_variable() { + Jail::expect_with(|jail| { + jail.clear_env(); + let err = load().expect_err("DATABASE_URL is required"); + assert!(err.to_string().contains("DATABASE_URL"), "{err}"); + Ok(()) + }); +} + +#[test] +fn malformed_numeric_env_value_fails_naming_the_variable() { + Jail::expect_with(|jail| { + jail_with(jail, &[("RATE_LIMIT", "abc")]); + let err = load().expect_err("RATE_LIMIT=abc is malformed"); + assert!(err.to_string().contains("RATE_LIMIT"), "{err}"); + Ok(()) + }); +} + +#[test] +fn allow_registration_accepts_true_and_1_only() { + for (value, expected) in [ + ("true", true), + ("1", true), + ("false", false), + ("0", false), + ("yes", false), + ("TRUE", false), + ] { + Jail::expect_with(|jail| { + jail_with(jail, &[("ALLOW_REGISTRATION", value)]); + let c = load().expect("config loads"); + assert_eq!(c.server.allow_registration, expected, "value {value:?}"); + Ok(()) + }); + } +} + +#[test] +fn secure_cookies_and_image_conversion_accept_true_only() { + Jail::expect_with(|jail| { + jail_with( + jail, + &[("SECURE_COOKIES", "1"), ("IMAGE_CONVERSION_ENABLED", "1")], + ); + let c = load().expect("config loads"); + assert!(!c.server.secure_cookies); + assert!(!c.image_conversion.enabled); + Ok(()) + }); +}