Improve environment variable handling and reading (#5389)

* wip: better env var reading

* move most env vars to env.rs

* migrate more env vars

* more migration

* more migrations

* More migration

* 🦀 dotenvy is gone (almost)

* 🦀 dotenvy is gone 🦀

* Fix mural source account env var handling

* Remove defaults from admin key vars

* dummy commit to update github pr

* fix ci
This commit is contained in:
aecsocket
2026-02-19 17:33:41 +00:00
committed by GitHub
parent b6b4bc21f1
commit ec81bcb13c
49 changed files with 636 additions and 661 deletions

View File

@@ -7,6 +7,9 @@ use serde_with::{DisplayFromStr, serde_as};
use thiserror::Error;
use tracing::trace;
use crate::env::ENV;
use crate::routes::ApiError;
pub fn transaction_id_stripe_pi(pi: &stripe::PaymentIntentId) -> String {
format!("stripe:charge:{pi}")
}
@@ -154,19 +157,14 @@ pub struct Client {
}
impl Client {
pub fn from_env() -> Result<Self, dotenvy::Error> {
let api_key = dotenvy::var("ANROK_API_KEY")?;
let api_url = dotenvy::var("ANROK_API_URL")?
.trim_start_matches('/')
.to_owned();
pub fn from_env() -> Result<Self, ApiError> {
Ok(Self {
client: reqwest::Client::builder()
.user_agent("Modrinth")
.build()
.expect("AnrokClient to build"),
api_key,
api_url,
api_key: ENV.ANROK_API_KEY.clone(),
api_url: ENV.ANROK_API_URL.trim_start_matches('/').to_owned(),
})
}

View File

@@ -2,6 +2,7 @@ use reqwest::header::HeaderName;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::env::ENV;
use crate::routes::ApiError;
const X_MASTER_KEY: HeaderName = HeaderName::from_static("x-master-key");
@@ -42,13 +43,12 @@ impl ArchonClient {
pub fn from_env() -> Result<Self, ApiError> {
let client = reqwest::Client::new();
let base_url =
dotenvy::var("ARCHON_URL")?.trim_end_matches('/').to_owned();
let base_url = ENV.ARCHON_URL.trim_end_matches('/').to_owned();
Ok(Self {
client,
base_url,
pyro_api_key: dotenvy::var("PYRO_API_KEY")?,
pyro_api_key: ENV.PYRO_API_KEY.clone(),
})
}

View File

@@ -1,4 +1,5 @@
use crate::database::models::{DBUserId, users_compliance::FormType};
use crate::env::ENV;
use crate::routes::ApiError;
use ariadne::ids::base62_impl::to_base62;
use chrono::Datelike;
@@ -131,10 +132,10 @@ fn team_request(
method: reqwest::Method,
route: &str,
) -> Result<(reqwest::RequestBuilder, String), ApiError> {
let key = dotenvy::var("AVALARA_1099_API_KEY")?;
let url = dotenvy::var("AVALARA_1099_API_URL")?;
let team = dotenvy::var("AVALARA_1099_API_TEAM_ID")?;
let company = dotenvy::var("AVALARA_1099_COMPANY_ID")?;
let key = &ENV.AVALARA_1099_API_KEY;
let url = &ENV.AVALARA_1099_API_URL;
let team = &ENV.AVALARA_1099_API_TEAM_ID;
let company = &ENV.AVALARA_1099_COMPANY_ID;
let url = url.trim_end_matches('/');
@@ -144,8 +145,8 @@ fn team_request(
client
.request(method, format!("{url}/v1/{team}{route}"))
.header(reqwest::header::USER_AGENT, "Modrinth")
.bearer_auth(&key),
company,
.bearer_auth(key),
company.to_string(),
))
}

View File

@@ -1,5 +1,5 @@
use crate::env::ENV;
use crate::routes::ApiError;
use crate::util::env::parse_var;
use actix_web::HttpRequest;
use serde::Deserialize;
use std::collections::HashMap;
@@ -8,7 +8,7 @@ pub async fn check_hcaptcha(
req: &HttpRequest,
challenge: &str,
) -> Result<bool, ApiError> {
let secret = dotenvy::var("HCAPTCHA_SECRET")?;
let secret = &ENV.HCAPTCHA_SECRET;
if secret.is_empty() || secret == "none" {
tracing::info!("hCaptcha secret not set, skipping check");
@@ -16,7 +16,7 @@ pub async fn check_hcaptcha(
}
let conn_info = req.connection_info().clone();
let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
let ip_addr = if ENV.CLOUDFLARE_INTEGRATION {
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().ok()
} else {
@@ -38,7 +38,7 @@ pub async fn check_hcaptcha(
let mut form = HashMap::new();
form.insert("response", challenge);
form.insert("secret", &*secret);
form.insert("secret", secret);
form.insert("remoteip", ip_addr);
let val: Response = client

View File

@@ -1,17 +0,0 @@
use std::str::FromStr;
use eyre::{Context, eyre};
pub fn env_var(key: &str) -> eyre::Result<String> {
dotenvy::var(key)
.wrap_err_with(|| eyre!("missing environment variable `{key}`"))
}
pub fn parse_var<T: FromStr>(var: &str) -> Option<T> {
dotenvy::var(var).ok().and_then(|i| i.parse().ok())
}
pub fn parse_strings_from_var(var: &'static str) -> Option<Vec<String>> {
dotenvy::var(var)
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
}

View File

@@ -1,8 +1,8 @@
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::ids::PayoutId;
use crate::routes::ApiError;
use crate::routes::internal::gotenberg::{GotenbergDocument, GotenbergError};
use crate::util::env::env_var;
use crate::util::error::Context;
use actix_web::http::header::HeaderName;
use chrono::{DateTime, Datelike, Utc};
@@ -71,15 +71,14 @@ impl GotenbergClient {
.build()
.wrap_err("failed to build reqwest client")?;
let gotenberg_url = env_var("GOTENBERG_URL")?;
let site_url = env_var("SITE_URL")?;
let callback_base = env_var("GOTENBERG_CALLBACK_BASE")?;
Ok(Self {
client,
gotenberg_url: gotenberg_url.trim_end_matches('/').to_owned(),
site_url: site_url.trim_end_matches('/').to_owned(),
callback_base: callback_base.trim_end_matches('/').to_owned(),
gotenberg_url: ENV.GOTENBERG_URL.trim_end_matches('/').to_owned(),
site_url: ENV.SITE_URL.trim_end_matches('/').to_owned(),
callback_base: ENV
.GOTENBERG_CALLBACK_BASE
.trim_end_matches('/')
.to_owned(),
redis,
})
}
@@ -189,12 +188,7 @@ impl GotenbergClient {
self.generate_payment_statement(statement).await?;
let timeout_ms = env_var("GOTENBERG_TIMEOUT")
.map_err(ApiError::Internal)?
.parse::<u64>()
.wrap_internal_err(
"`GOTENBERG_TIMEOUT` is not a valid number of milliseconds",
)?;
let timeout_ms = ENV.GOTENBERG_TIMEOUT;
let [_key, document] = tokio::time::timeout(
Duration::from_millis(timeout_ms),

View File

@@ -1,47 +1,33 @@
use actix_web::guard::GuardContext;
use actix_web::http::header::X_FORWARDED_FOR;
use crate::env::ENV;
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
pub const EXTERNAL_NOTIFICATION_KEY_HEADER: &str = "External-Notification-Key";
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
"No admin key provided, this should have been caught by check_env_vars",
);
ctx.head()
.headers()
.get(ADMIN_KEY_HEADER)
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
.is_some_and(|it| it.as_bytes() == ENV.LABRINTH_ADMIN_KEY.as_bytes())
}
pub fn medal_key_guard(ctx: &GuardContext) -> bool {
let maybe_medal_key = dotenvy::var("LABRINTH_MEDAL_KEY").ok();
match maybe_medal_key {
None => false,
Some(medal_key) => ctx
.head()
.headers()
.get(MEDAL_KEY_HEADER)
.is_some_and(|it| it.as_bytes() == medal_key.as_bytes()),
}
ctx.head()
.headers()
.get(MEDAL_KEY_HEADER)
.is_some_and(|it| it.as_bytes() == ENV.LABRINTH_MEDAL_KEY.as_bytes())
}
pub fn external_notification_key_guard(ctx: &GuardContext) -> bool {
let maybe_external_notification_key =
dotenvy::var("LABRINTH_EXTERNAL_NOTIFICATION_KEY").ok();
match maybe_external_notification_key {
None => false,
Some(external_notification_key) => ctx
.head()
.headers()
.get(EXTERNAL_NOTIFICATION_KEY_HEADER)
.is_some_and(|it| {
it.as_bytes() == external_notification_key.as_bytes()
}),
}
ctx.head()
.headers()
.get(EXTERNAL_NOTIFICATION_KEY_HEADER)
.is_some_and(|it| {
it.as_bytes() == ENV.LABRINTH_EXTERNAL_NOTIFICATION_KEY.as_bytes()
})
}
pub fn internal_network_guard(ctx: &GuardContext) -> bool {

View File

@@ -1,6 +1,7 @@
use crate::database::models::image_item;
use crate::database::redis::RedisPool;
use crate::database::{self, PgTransaction};
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::images::ImageContext;
use crate::routes::ApiError;
@@ -59,7 +60,7 @@ pub async fn upload_image_optimized(
))
})?;
let cdn_url = dotenvy::var("CDN_URL")?;
let cdn_url = &ENV.CDN_URL;
let hash = sha1::Sha1::digest(&bytes).encode_hex::<String>();
let (processed_image, processed_image_ext) = process_image(
@@ -175,7 +176,7 @@ pub async fn delete_old_images(
publicity: FileHostPublicity,
file_host: &dyn FileHost,
) -> Result<(), ApiError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let cdn_url = &ENV.CDN_URL;
let cdn_url_start = format!("{cdn_url}/");
if let Some(image_url) = image_url {
let name = image_url.split(&cdn_url_start).nth(1);

View File

@@ -6,7 +6,6 @@ pub mod bitflag;
pub mod captcha;
pub mod cors;
pub mod date;
pub mod env;
pub mod error;
pub mod ext;
pub mod gotenberg;

View File

@@ -1,6 +1,5 @@
use crate::database::redis::RedisPool;
use crate::routes::ApiError;
use crate::util::env::parse_var;
use crate::{database::redis::RedisPool, env::ENV};
use actix_web::{
Error, ResponseError,
body::{EitherBody, MessageBody},
@@ -134,14 +133,13 @@ pub async fn rate_limit_middleware(
.clone();
if let Some(key) = req.headers().get("x-ratelimit-key")
&& key.to_str().ok()
== dotenvy::var("RATE_LIMIT_IGNORE_KEY").ok().as_deref()
&& key.to_str().ok() == Some(&ENV.RATE_LIMIT_IGNORE_KEY)
{
return Ok(next.call(req).await?.map_into_left_body());
}
let conn_info = req.connection_info().clone();
let ip = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
let ip = if ENV.CLOUDFLARE_INTEGRATION {
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().ok()
} else {

View File

@@ -1,8 +1,8 @@
use crate::database::PgPool;
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::redis::RedisPool;
use crate::models::ids::ProjectId;
use crate::routes::ApiError;
use crate::{database::PgPool, env::ENV};
use ariadne::ids::base62_impl::to_base62;
use chrono::{DateTime, Utc};
use serde::Serialize;
@@ -69,7 +69,7 @@ async fn get_webhook_metadata(
name: organization.name,
url: format!(
"{}/organization/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
ENV.SITE_URL,
to_base62(organization.id.0 as u64)
),
icon_url: organization.icon_url,
@@ -95,7 +95,7 @@ async fn get_webhook_metadata(
owner = Some(WebhookAuthor {
url: format!(
"{}/user/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
ENV.SITE_URL,
to_base62(user.id.0 as u64)
),
name: user.username,
@@ -142,7 +142,7 @@ async fn get_webhook_metadata(
Ok(Some(WebhookMetadata {
project_url: format!(
"{}/{}/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
ENV.SITE_URL,
project_type,
to_base62(project.inner.id.0 as u64)
),
@@ -251,7 +251,7 @@ pub async fn send_slack_project_webhook(
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
webhook_url: String,
webhook_url: &str,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
@@ -350,7 +350,7 @@ pub async fn send_slack_project_webhook(
let client = reqwest::Client::new();
client
.post(&webhook_url)
.post(webhook_url)
.json(&serde_json::json!({
"blocks": blocks,
}))
@@ -422,7 +422,7 @@ pub async fn send_discord_webhook(
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
webhook_url: String,
webhook_url: &str,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
@@ -482,7 +482,7 @@ pub async fn send_discord_webhook(
let client = reqwest::Client::new();
client
.post(&webhook_url)
.post(webhook_url)
.json(&DiscordWebhook {
avatar_url: Some(
"https://cdn.modrinth.com/Modrinth_Dark_Logo.png"