use std::{any::type_name, convert::Infallible, str::FromStr, sync::LazyLock}; use derive_more::{Deref, DerefMut}; use eyre::{Context, eyre}; use rust_decimal::Decimal; use serde::de::DeserializeOwned; macro_rules! vars { ( $( $field:ident: $ty:ty $(= $default:expr)?; )* ) => { #[derive(Debug)] #[allow( non_snake_case, reason = "environment variables are UPPER_SNAKE_CASE", )] pub struct EnvVars { $( pub $field: $ty, )* } impl EnvVars { pub fn from_env() -> eyre::Result { let mut err = eyre!("failed to read environment variables"); $( #[expect( non_snake_case, reason = "environment variables are UPPER_SNAKE_CASE", )] #[allow( unused_assignments, unused_mut, reason = "`default` is not used if there is no default", )] let $field: Option<$ty> = { let mut default = None::<$ty>; $( default = Some({ $default }.into()); )? match parse_value::<$ty>(stringify!($field), default) { Ok(value) => Some(value), Err(source) => { err = err.wrap_err(eyre!("{source:#}")); None } } }; )* Ok(EnvVars { $( $field: match $field { Some(value) => value, None => return Err(err), }, )* }) } } }; } pub static ENV: LazyLock = LazyLock::new(|| { EnvVars::from_env().unwrap_or_else(|err| panic!("{err:?}")) }); fn parse_value(key: &str, default: Option) -> eyre::Result where T: FromStr, T::Err: std::error::Error + Send + Sync + 'static, { match (dotenvy::var(key), default) { (Ok(value), _) => value.parse::().wrap_err_with(|| { eyre!("`{key}` is not a valid `{}`", type_name::()) }), (Err(_), Some(default)) => Ok(default), (Err(_), None) => Err(eyre!("`{key}` missing")), } } pub fn init() -> eyre::Result<()> { dotenvy::dotenv().ok(); EnvVars::from_env()?; LazyLock::force(&ENV); Ok(()) } #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deref, DerefMut, )] pub struct Json(pub T); impl FromStr for Json { type Err = serde_json::Error; fn from_str(s: &str) -> Result { serde_json::from_str(s).map(Self) } } #[derive( Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deref, DerefMut, )] pub struct StringCsv(pub Vec); impl FromStr for StringCsv { type Err = Infallible; fn from_str(s: &str) -> Result { let v = s .split(',') .filter(|s| !s.trim().is_empty()) .map(|s| s.to_string()) .collect::>(); Ok(Self(v)) } } vars! { SENTRY_ENVIRONMENT: String; SENTRY_TRACES_SAMPLE_RATE: f32; SITE_URL: String; CDN_URL: String; LABRINTH_ADMIN_KEY: String; LABRINTH_MEDAL_KEY: String; LABRINTH_EXTERNAL_NOTIFICATION_KEY: String; RATE_LIMIT_IGNORE_KEY: String; DATABASE_URL: String; REDIS_URL: String; BIND_ADDR: String; SELF_ADDR: String; LOCAL_INDEX_INTERVAL: u64; VERSION_INDEX_INTERVAL: u64; WHITELISTED_MODPACK_DOMAINS: Json>; ALLOWED_CALLBACK_URLS: Json>; ANALYTICS_ALLOWED_ORIGINS: Json>; // search SEARCH_BACKEND: crate::search::SearchBackendKind = crate::search::SearchBackendKind::Typesense; MEILISEARCH_READ_ADDR: String; MEILISEARCH_WRITE_ADDRS: StringCsv; MEILISEARCH_KEY: String; ELASTICSEARCH_URL: String; ELASTICSEARCH_INDEX_PREFIX: String; ELASTICSEARCH_USERNAME: String = ""; ELASTICSEARCH_PASSWORD: String = ""; SEARCH_INDEX_CHUNK_SIZE: i64 = 5000i64; TYPESENSE_URL: String = "http://localhost:8108"; TYPESENSE_API_KEY: String = "modrinth"; TYPESENSE_INDEX_PREFIX: String = "labrinth"; // storage STORAGE_BACKEND: crate::file_hosting::FileHostKind; // s3 S3_PUBLIC_BUCKET_NAME: String = ""; S3_PUBLIC_USES_PATH_STYLE_BUCKET: bool = false; S3_PUBLIC_REGION: String = ""; S3_PUBLIC_URL: String = ""; S3_PUBLIC_ACCESS_TOKEN: String = ""; S3_PUBLIC_SECRET: String = ""; S3_PRIVATE_BUCKET_NAME: String = ""; S3_PRIVATE_USES_PATH_STYLE_BUCKET: bool = false; S3_PRIVATE_REGION: String = ""; S3_PRIVATE_URL: String = ""; S3_PRIVATE_ACCESS_TOKEN: String = ""; S3_PRIVATE_SECRET: String = ""; // local MOCK_FILE_PATH: String = ""; GITHUB_CLIENT_ID: String; GITHUB_CLIENT_SECRET: String; GITLAB_CLIENT_ID: String; GITLAB_CLIENT_SECRET: String; DISCORD_CLIENT_ID: String; DISCORD_CLIENT_SECRET: String; MICROSOFT_CLIENT_ID: String; MICROSOFT_CLIENT_SECRET: String; GOOGLE_CLIENT_ID: String; GOOGLE_CLIENT_SECRET: String; STEAM_API_KEY: String; TREMENDOUS_API_URL: String; TREMENDOUS_API_KEY: String; TREMENDOUS_PRIVATE_KEY: String; PAYPAL_API_URL: String; PAYPAL_WEBHOOK_ID: String; PAYPAL_CLIENT_ID: String; PAYPAL_CLIENT_SECRET: String; PAYPAL_NVP_USERNAME: String; PAYPAL_NVP_PASSWORD: String; PAYPAL_NVP_SIGNATURE: String; PAYPAL_BALANCE_ALERT_THRESHOLD: u64 = 0u64; BREX_BALANCE_ALERT_THRESHOLD: u64 = 0u64; TREMENDOUS_BALANCE_ALERT_THRESHOLD: u64 = 0u64; MURAL_BALANCE_ALERT_THRESHOLD: u64 = 0u64; HCAPTCHA_SECRET: String; SMTP_USERNAME: String; SMTP_PASSWORD: String; SMTP_HOST: String; SMTP_PORT: u16; SMTP_TLS: String; SMTP_FROM_NAME: String; SMTP_FROM_ADDRESS: String; SITE_VERIFY_EMAIL_PATH: String; SITE_RESET_PASSWORD_PATH: String; SITE_BILLING_PATH: String; SENDY_URL: String; SENDY_LIST_ID: String; SENDY_API_KEY: String; CLICKHOUSE_REPLICATED: bool; CLICKHOUSE_URL: String; CLICKHOUSE_USER: String; CLICKHOUSE_PASSWORD: String; CLICKHOUSE_DATABASE: String; FLAME_ANVIL_URL: String; GOTENBERG_URL: String; GOTENBERG_CALLBACK_BASE: String; GOTENBERG_TIMEOUT: u64; STRIPE_API_KEY: String; STRIPE_WEBHOOK_SECRET: String; ADITUDE_API_KEY: String; PYRO_API_KEY: String; BREX_API_URL: String; BREX_API_KEY: String; DELPHI_URL: String; AVALARA_1099_API_URL: String; AVALARA_1099_API_KEY: String; AVALARA_1099_API_TEAM_ID: String; AVALARA_1099_COMPANY_ID: String; ANROK_API_URL: String; ANROK_API_KEY: String; PAYOUT_ALERT_SLACK_WEBHOOK: String; CLOUDFLARE_INTEGRATION: bool = false; ARCHON_URL: String; MURALPAY_API_URL: String; MURALPAY_API_KEY: String; MURALPAY_TRANSFER_API_KEY: String; MURALPAY_SOURCE_ACCOUNT_ID: muralpay::AccountId = muralpay::AccountId(uuid::Uuid::nil()); DEFAULT_AFFILIATE_REVENUE_SPLIT: Decimal; DATABASE_ACQUIRE_TIMEOUT_MS: u64 = 30000u64; DATABASE_MIN_CONNECTIONS: u32 = 0u32; DATABASE_MAX_CONNECTIONS: u32 = 16u32; READONLY_DATABASE_URL: String = ""; READONLY_DATABASE_MIN_CONNECTIONS: u32 = 0u32; READONLY_DATABASE_MAX_CONNECTIONS: u32 = 1u32; REDIS_WAIT_TIMEOUT_MS: u64 = 15000u64; REDIS_MAX_CONNECTIONS: u32 = 10000u32; REDIS_MIN_CONNECTIONS: usize = 0usize; SEARCH_OPERATION_TIMEOUT: u64 = 300000u64; SMTP_REPLY_TO_NAME: String = ""; SMTP_REPLY_TO_ADDRESS: String = ""; PUBLIC_DISCORD_WEBHOOK: String = ""; MODERATION_SLACK_WEBHOOK: String = ""; DELPHI_SLACK_WEBHOOK: String = ""; TREMENDOUS_CAMPAIGN_ID: String = ""; // server pinging SERVER_PING_MAX_CONCURRENT: usize = 16usize; SERVER_PING_RETRIES: usize = 3usize; SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60; SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000; }