mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 01:26:23 +00:00
* redo error handling in xredis * give proper types to metadata fields * add round-trip tests * inline loader enum metadata fields * postcard roundtrips * prepare * bump redis key version * serde-binhum * clippy * fix * fix frontend checking existence of component fields rather than non-null-ness * prepare
400 lines
14 KiB
Rust
400 lines
14 KiB
Rust
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;
|
|
|
|
use xredis::{
|
|
CacheLockingStrategy, ReadReplicaStrategy, RedisConnectionType,
|
|
RedisTopology,
|
|
};
|
|
|
|
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<Self> {
|
|
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(<$ty>::from({ $default })); )?
|
|
|
|
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<EnvVars> = LazyLock::new(|| {
|
|
EnvVars::from_env().unwrap_or_else(|err| panic!("{err:?}"))
|
|
});
|
|
|
|
fn parse_value<T>(key: &str, default: Option<T>) -> eyre::Result<T>
|
|
where
|
|
T: FromStr,
|
|
T::Err: std::error::Error + Send + Sync + 'static,
|
|
{
|
|
match (dotenvy::var(key), default) {
|
|
(Ok(value), _) => value.parse::<T>().wrap_err_with(|| {
|
|
eyre!("`{key}` is not a valid `{}`", type_name::<T>())
|
|
}),
|
|
(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<T: DeserializeOwned>(pub T);
|
|
|
|
impl<T: DeserializeOwned> FromStr for Json<T> {
|
|
type Err = serde_json::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
serde_json::from_str(s).map(Self)
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deref, DerefMut,
|
|
)]
|
|
pub struct StringCsv(pub Vec<String>);
|
|
|
|
impl FromStr for StringCsv {
|
|
type Err = Infallible;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
let v = s
|
|
.split(',')
|
|
.filter(|s| !s.trim().is_empty())
|
|
.map(|s| s.to_string())
|
|
.collect::<Vec<_>>();
|
|
Ok(Self(v))
|
|
}
|
|
}
|
|
|
|
vars! {
|
|
SENTRY_ENVIRONMENT: String = "development";
|
|
SENTRY_TRACES_SAMPLE_RATE: f32 = 0.1f32;
|
|
SITE_URL: String = "http://localhost:3000";
|
|
CDN_URL: String = "file:///tmp/modrinth";
|
|
LABRINTH_ADMIN_KEY: String = "";
|
|
LABRINTH_MEDAL_KEY: String = "";
|
|
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String = "";
|
|
LABRINTH_SUBSCRIPTIONS_KEY: String = "";
|
|
RATE_LIMIT_IGNORE_KEY: String = "";
|
|
DATABASE_URL: String = "postgresql://labrinth:labrinth@localhost/labrinth";
|
|
|
|
// Redis
|
|
REDIS_TOPOLOGY: RedisTopology = RedisTopology::Standalone;
|
|
REDIS_CONNECTION_TYPE: RedisConnectionType = RedisConnectionType::Pooled;
|
|
REDIS_CACHE_LOCKING_STRATEGY: CacheLockingStrategy = CacheLockingStrategy::Local;
|
|
// URL(s) for Redis. Use comma-separated values for multiple URLs in Cluster topology.
|
|
REDIS_URL: String = "redis://localhost";
|
|
|
|
// Configures the waiting timeout for Redis connection *pools*.
|
|
// This doesn't affect the bulk of Redis work in Multiplexed connection type.
|
|
REDIS_WAIT_TIMEOUT_MS: u64 = 15000u64;
|
|
|
|
// Minimum and maximum number of connections when Redis is in Standalone topology.
|
|
REDIS_MAX_CONNECTIONS: u32 = 2048u32;
|
|
REDIS_MIN_CONNECTIONS: usize = 0usize;
|
|
|
|
REDIS_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
|
|
REDIS_ACTUAL_EXPIRY: i64 = 60 * 30;
|
|
REDIS_VERSION_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
|
|
REDIS_VERSION_ACTUAL_EXPIRY: i64 = 60 * 30;
|
|
|
|
// Minimum and maximum number of connections when Redis is in Cluster topology, Pooled connection type.
|
|
REDIS_CLUSTER_MAX_CONNECTIONS: u32 = 16u32;
|
|
REDIS_CLUSTER_MIN_CONNECTIONS: usize = 0usize;
|
|
|
|
// The maximum number of connections of the Redis blocking pool. There's a blocking pool regardless of topology
|
|
// and main connection type.
|
|
REDIS_BLOCKING_MAX_CONNECTIONS: u32 = 256u32;
|
|
|
|
// The encoding format used for Redis cache values.
|
|
REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Postcard;
|
|
// The level of LZ4 compression used for Redis cache values. A value of 0 disables compression (supports 1-12)
|
|
REDIS_COMPRESSION_LEVEL: i32 = 0i32;
|
|
// The compression algorithm used for Redis cache values. Currently only LZ4 is supported.
|
|
REDIS_COMPRESSION_ALGORITHM: xredis::Codec = xredis::Codec::Lz4;
|
|
// The minimum number of bytes required to trigger compression for Redis cache values.
|
|
REDIS_COMPRESSION_THRESHOLD_BYTES: usize = 1024usize;
|
|
// The minimum savings ratio required to trigger compression for Redis cache values. If the savings ratio is lower than this,
|
|
// the compressed payload is discarded and the original payload is stored as-is.
|
|
REDIS_COMPRESSION_MIN_SAVINGS_RATIO: f64 = 12.5f64;
|
|
|
|
REDIS_READ_REPLICA_STRATEGY: ReadReplicaStrategy = ReadReplicaStrategy::Primary;
|
|
|
|
KAFKA_BOOTSTRAP_SERVERS: StringCsv = StringCsv(vec!["localhost:19092".into()]);
|
|
KAFKA_CLIENT_ID: String = "labrinth";
|
|
BIND_ADDR: String = "";
|
|
SELF_ADDR: String = "";
|
|
|
|
LOCAL_INDEX_INTERVAL: u64 = 3600u64;
|
|
VERSION_INDEX_INTERVAL: u64 = 1800u64;
|
|
|
|
WHITELISTED_MODPACK_DOMAINS: Json<Vec<String>> = Json(vec![
|
|
"cdn.modrinth.com".into(),
|
|
"github.com".into(),
|
|
"raw.githubusercontent.com".into(),
|
|
]);
|
|
ALLOWED_CALLBACK_URLS: Json<Vec<String>> = Json(vec![
|
|
"localhost".into(),
|
|
".modrinth.com".into(),
|
|
"127.0.0.1".into(),
|
|
"[::1]".into(),
|
|
]);
|
|
ANALYTICS_ALLOWED_ORIGINS: Json<Vec<String>> = Json(vec![
|
|
"http://127.0.0.1:3000".into(),
|
|
"http://localhost:3000".into(),
|
|
"https://modrinth.com".into(),
|
|
"https://www.modrinth.com".into(),
|
|
"*".into(),
|
|
]);
|
|
|
|
// search
|
|
SEARCH_BACKEND: crate::search::SearchBackendKind = crate::search::SearchBackendKind::Typesense;
|
|
SEARCH_INDEX_CHUNK_SIZE: i64 = 5000i64;
|
|
SEARCH_INCREMENTAL_INDEX_BATCH_DELAY_SECONDS: u64 = 5u64;
|
|
SEARCH_INCREMENTAL_INDEX_BATCH_MAX_SIZE: usize = 1000usize;
|
|
TYPESENSE_URL: String = "http://localhost:8108";
|
|
TYPESENSE_API_KEY: String = "modrinth";
|
|
TYPESENSE_INDEX_PREFIX: String = "labrinth";
|
|
TYPESENSE_IMPORT_BATCH_SIZE: usize = 5000usize;
|
|
TYPESENSE_DELETE_BATCH_SIZE: usize = 10_000usize;
|
|
TYPESENSE_USE_CACHE: bool = true;
|
|
SEARCH_TYPESENSE_DEFAULT_QUERY_BY: StringCsv = StringCsv(vec![
|
|
"name".into(),
|
|
"indexed_name".into(),
|
|
"slug".into(),
|
|
"author".into(),
|
|
"indexed_author".into(),
|
|
"summary".into(),
|
|
]);
|
|
SEARCH_TYPESENSE_DEFAULT_QUERY_BY_WEIGHTS: Json<Vec<u8>> =
|
|
Json(vec![15, 15, 10, 3, 3, 1]);
|
|
SEARCH_TYPESENSE_DEFAULT_PREFIX: Json<Vec<bool>> =
|
|
Json(vec![true, true, true, true, true, true]);
|
|
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_EXACT_MATCH: bool = true;
|
|
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_NUM_MATCHING_FIELDS: bool = false;
|
|
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_TOKEN_POSITIONS: bool = true;
|
|
SEARCH_TYPESENSE_DEFAULT_DROP_TOKENS_THRESHOLD: usize = 0usize;
|
|
SEARCH_TYPESENSE_DEFAULT_TEXT_MATCH_TYPE: crate::search::backend::typesense::TextMatchType =
|
|
crate::search::backend::typesense::TextMatchType::MaxWeight;
|
|
SEARCH_TYPESENSE_DEFAULT_BUCKETING: Json<crate::search::backend::typesense::Bucketing> =
|
|
Json(crate::search::backend::typesense::Bucketing::Buckets(5));
|
|
SEARCH_TYPESENSE_DEFAULT_MAX_CANDIDATES: usize = 24usize;
|
|
ELASTICSEARCH_URL: String = "http://localhost:9200";
|
|
ELASTICSEARCH_INDEX_PREFIX: String = "labrinth";
|
|
ELASTICSEARCH_USERNAME: String = "";
|
|
ELASTICSEARCH_PASSWORD: String = "";
|
|
ELASTICSEARCH_BULK_BATCH_SIZE: usize = 1000usize;
|
|
|
|
// storage
|
|
STORAGE_BACKEND: crate::file_hosting::FileHostKind = crate::file_hosting::FileHostKind::Local;
|
|
FILE_SCAN_CONCURRENCY: i64 = 8i64;
|
|
|
|
// 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 = "/tmp/modrinth";
|
|
|
|
GITHUB_CLIENT_ID: String = "none";
|
|
GITHUB_CLIENT_SECRET: String = "none";
|
|
GITLAB_CLIENT_ID: String = "none";
|
|
GITLAB_CLIENT_SECRET: String = "none";
|
|
DISCORD_CLIENT_ID: String = "none";
|
|
DISCORD_CLIENT_SECRET: String = "none";
|
|
DISCORD_COMMUNITY_BOT_HANDOFF_URL: String = "http://localhost:3000/modrinth/handoff";
|
|
DISCORD_COMMUNITY_LINK_SECRET: String = "";
|
|
MICROSOFT_CLIENT_ID: String = "none";
|
|
MICROSOFT_CLIENT_SECRET: String = "none";
|
|
GOOGLE_CLIENT_ID: String = "none";
|
|
GOOGLE_CLIENT_SECRET: String = "none";
|
|
STEAM_API_KEY: String = "none";
|
|
|
|
TREMENDOUS_API_URL: String = "https://testflight.tremendous.com/api/v2/";
|
|
TREMENDOUS_API_KEY: String = "none";
|
|
TREMENDOUS_PRIVATE_KEY: String = "none";
|
|
|
|
PAYPAL_API_URL: String = "https://api-m.sandbox.paypal.com/v1/";
|
|
PAYPAL_WEBHOOK_ID: String = "none";
|
|
PAYPAL_CLIENT_ID: String = "none";
|
|
PAYPAL_CLIENT_SECRET: String = "none";
|
|
PAYPAL_NVP_USERNAME: String = "none";
|
|
PAYPAL_NVP_PASSWORD: String = "none";
|
|
PAYPAL_NVP_SIGNATURE: String = "none";
|
|
|
|
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 = "none";
|
|
|
|
SMTP_USERNAME: String = "";
|
|
SMTP_PASSWORD: String = "";
|
|
SMTP_HOST: String = "localhost";
|
|
SMTP_PORT: u16 = 1025u16;
|
|
SMTP_TLS: String = "none";
|
|
SMTP_FROM_NAME: String = "Modrinth";
|
|
SMTP_FROM_ADDRESS: String = "no-reply@mail.modrinth.com";
|
|
|
|
SITE_VERIFY_EMAIL_PATH: String = "auth/verify-email";
|
|
SITE_RESET_PASSWORD_PATH: String = "auth/reset-password";
|
|
SITE_BILLING_PATH: String = "none";
|
|
|
|
SENDY_URL: String = "none";
|
|
SENDY_LIST_ID: String = "none";
|
|
SENDY_API_KEY: String = "none";
|
|
|
|
NEVERBOUNCE_API_KEY: String = "";
|
|
NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL;
|
|
|
|
EMAIL_DOMAIN_BLACKLIST: StringCsv = StringCsv(vec![]);
|
|
|
|
CLICKHOUSE_REPLICATED: bool = false;
|
|
CLICKHOUSE_URL: String = "http://localhost:8123";
|
|
CLICKHOUSE_USER: String = "default";
|
|
CLICKHOUSE_PASSWORD: String = "default";
|
|
CLICKHOUSE_DATABASE: String = "staging_ariadne";
|
|
|
|
FLAME_ANVIL_URL: String = "none";
|
|
|
|
GOTENBERG_URL: String = "http://localhost:13000";
|
|
GOTENBERG_CALLBACK_BASE: String = "http://host.docker.internal:8000/_internal/gotenberg";
|
|
GOTENBERG_TIMEOUT: u64 = 30000u64;
|
|
|
|
STRIPE_API_KEY: String = "none";
|
|
STRIPE_WEBHOOK_SECRET: String = "none";
|
|
|
|
ADITUDE_API_KEY: String = "none";
|
|
|
|
PYRO_API_KEY: String = "none";
|
|
|
|
BREX_API_URL: String = "https://platform.brexapis.com/v2/";
|
|
BREX_API_KEY: String = "none";
|
|
|
|
DELPHI_URL: String = "";
|
|
|
|
SHARED_INSTANCES_URL: String = "";
|
|
SHARED_INSTANCES_KEY: String = "";
|
|
|
|
AVALARA_1099_API_URL: String = "https://www.track1099.com/api";
|
|
AVALARA_1099_API_KEY: String = "none";
|
|
AVALARA_1099_API_TEAM_ID: String = "none";
|
|
AVALARA_1099_COMPANY_ID: String = "207337084";
|
|
|
|
ANROK_API_URL: String = "";
|
|
ANROK_API_KEY: String = "";
|
|
|
|
PAYOUT_ALERT_SLACK_WEBHOOK: String = "none";
|
|
CLOUDFLARE_INTEGRATION: bool = false;
|
|
|
|
ARCHON_URL: String = "";
|
|
|
|
MURALPAY_API_URL: String = "https://api-staging.muralpay.com";
|
|
MURALPAY_API_KEY: String = "none";
|
|
MURALPAY_TRANSFER_API_KEY: String = "none";
|
|
MURALPAY_SOURCE_ACCOUNT_ID: muralpay::AccountId = muralpay::AccountId(uuid::Uuid::nil());
|
|
|
|
DEFAULT_AFFILIATE_REVENUE_SPLIT: Decimal = Decimal::new(1, 1);
|
|
|
|
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;
|
|
|
|
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 = "none";
|
|
TILTIFY_CLIENT_ID: String = "";
|
|
TILTIFY_CLIENT_SECRET: String = "";
|
|
TILTIFY_PRIDE_26_CAMPAIGN_ID: String = "";
|
|
TILTIFY_WEBHOOK_SIGNING_KEY: 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;
|
|
SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64;
|
|
|
|
WEBAUTHN_RP_NAME: String = "Modrinth";
|
|
}
|