You've already forked AstralRinth
forked from didirus/AstralRinth
Use new MaxMind env vars on Labrinth (#4573)
* Bring in modrinth-maxmind * integrate modrinth-maxmind into labrinth * Fix CI
This commit is contained in:
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -2343,6 +2343,15 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -4620,7 +4629,6 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"either",
|
"either",
|
||||||
"eyre",
|
"eyre",
|
||||||
"flate2",
|
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -4633,8 +4641,8 @@ dependencies = [
|
|||||||
"jemalloc_pprof",
|
"jemalloc_pprof",
|
||||||
"json-patch 4.1.0",
|
"json-patch 4.1.0",
|
||||||
"lettre",
|
"lettre",
|
||||||
"maxminddb",
|
|
||||||
"meilisearch-sdk",
|
"meilisearch-sdk",
|
||||||
|
"modrinth-maxmind",
|
||||||
"murmur2",
|
"murmur2",
|
||||||
"paste",
|
"paste",
|
||||||
"path-util",
|
"path-util",
|
||||||
@@ -4658,7 +4666,6 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"spdx",
|
"spdx",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tar",
|
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tikv-jemalloc-ctl",
|
"tikv-jemalloc-ctl",
|
||||||
"tikv-jemallocator",
|
"tikv-jemallocator",
|
||||||
@@ -5152,6 +5159,35 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "modrinth-maxmind"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"clap",
|
||||||
|
"directories",
|
||||||
|
"eyre",
|
||||||
|
"flate2",
|
||||||
|
"maxminddb",
|
||||||
|
"modrinth-util",
|
||||||
|
"reqwest",
|
||||||
|
"tar",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "modrinth-util"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"actix-web",
|
||||||
|
"derive_more 2.0.1",
|
||||||
|
"dotenvy",
|
||||||
|
"eyre",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moka"
|
name = "moka"
|
||||||
version = "0.12.11"
|
version = "0.12.11"
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ members = [
|
|||||||
"packages/app-lib",
|
"packages/app-lib",
|
||||||
"packages/ariadne",
|
"packages/ariadne",
|
||||||
"packages/daedalus",
|
"packages/daedalus",
|
||||||
|
"packages/modrinth-maxmind",
|
||||||
|
"packages/modrinth-util",
|
||||||
"packages/path-util",
|
"packages/path-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version = "1.90.0"
|
||||||
|
repository = "https://github.com/modrinth/code"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
actix-cors = "0.7.1"
|
actix-cors = "0.7.1"
|
||||||
@@ -57,6 +61,7 @@ dashmap = "6.1.0"
|
|||||||
data-url = "0.3.2"
|
data-url = "0.3.2"
|
||||||
deadpool-redis = "0.22.0"
|
deadpool-redis = "0.22.0"
|
||||||
derive_more = "2.0.1"
|
derive_more = "2.0.1"
|
||||||
|
directories = "6.0.0"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
discord-rich-presence = "1.0.0"
|
discord-rich-presence = "1.0.0"
|
||||||
dotenv-build = "0.1.1"
|
dotenv-build = "0.1.1"
|
||||||
@@ -102,6 +107,8 @@ lettre = { version = "0.11.19", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
maxminddb = "0.26.0"
|
maxminddb = "0.26.0"
|
||||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||||
|
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
|
||||||
|
modrinth-util = { path = "packages/modrinth-util" }
|
||||||
murmur2 = "0.1.0"
|
murmur2 = "0.1.0"
|
||||||
native-dialog = "0.9.2"
|
native-dialog = "0.9.2"
|
||||||
notify = { version = "8.2.0", default-features = false }
|
notify = { version = "8.2.0", default-features = false }
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ CLICKHOUSE_USER=default
|
|||||||
CLICKHOUSE_PASSWORD=default
|
CLICKHOUSE_PASSWORD=default
|
||||||
CLICKHOUSE_DATABASE=staging_ariadne
|
CLICKHOUSE_DATABASE=staging_ariadne
|
||||||
|
|
||||||
|
MAXMIND_ACCOUNT_ID=none
|
||||||
MAXMIND_LICENSE_KEY=none
|
MAXMIND_LICENSE_KEY=none
|
||||||
|
|
||||||
FLAME_ANVIL_URL=none
|
FLAME_ANVIL_URL=none
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ CLICKHOUSE_USER=default
|
|||||||
CLICKHOUSE_PASSWORD=default
|
CLICKHOUSE_PASSWORD=default
|
||||||
CLICKHOUSE_DATABASE=staging_ariadne
|
CLICKHOUSE_DATABASE=staging_ariadne
|
||||||
|
|
||||||
|
MAXMIND_ACCOUNT_ID=none
|
||||||
MAXMIND_LICENSE_KEY=none
|
MAXMIND_LICENSE_KEY=none
|
||||||
|
|
||||||
FLAME_ANVIL_URL=none
|
FLAME_ANVIL_URL=none
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ deadpool-redis.workspace = true
|
|||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
either = { workspace = true }
|
either = { workspace = true }
|
||||||
eyre = { workspace = true }
|
eyre = { workspace = true }
|
||||||
flate2 = { workspace = true }
|
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
@@ -69,8 +68,8 @@ image = { workspace = true, features = [
|
|||||||
itertools = { workspace = true }
|
itertools = { workspace = true }
|
||||||
json-patch = { workspace = true }
|
json-patch = { workspace = true }
|
||||||
lettre = { workspace = true }
|
lettre = { workspace = true }
|
||||||
maxminddb = { workspace = true }
|
|
||||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||||
|
modrinth-maxmind = { workspace = true }
|
||||||
murmur2 = { workspace = true }
|
murmur2 = { workspace = true }
|
||||||
paste = { workspace = true }
|
paste = { workspace = true }
|
||||||
path-util = { workspace = true }
|
path-util = { workspace = true }
|
||||||
@@ -111,7 +110,6 @@ sqlx = { workspace = true, features = [
|
|||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"tls-rustls-aws-lc-rs",
|
"tls-rustls-aws-lc-rs",
|
||||||
] }
|
] }
|
||||||
tar = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "sync"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "sync"] }
|
||||||
tokio-stream = { workspace = true }
|
tokio-stream = { workspace = true }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
use database::redis::RedisPool;
|
use database::redis::RedisPool;
|
||||||
|
use modrinth_maxmind::MaxMind;
|
||||||
use queue::{
|
use queue::{
|
||||||
analytics::AnalyticsQueue, email::EmailQueue, payouts::PayoutsQueue,
|
analytics::AnalyticsQueue, email::EmailQueue, payouts::PayoutsQueue,
|
||||||
session::AuthQueue, socket::ActiveSockets,
|
session::AuthQueue, socket::ActiveSockets,
|
||||||
@@ -49,7 +50,7 @@ pub struct LabrinthConfig {
|
|||||||
pub redis_pool: RedisPool,
|
pub redis_pool: RedisPool,
|
||||||
pub clickhouse: Client,
|
pub clickhouse: Client,
|
||||||
pub file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
pub file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||||
pub maxmind: Arc<queue::maxmind::MaxMindIndexer>,
|
pub maxmind: web::Data<MaxMind>,
|
||||||
pub scheduler: Arc<scheduler::Scheduler>,
|
pub scheduler: Arc<scheduler::Scheduler>,
|
||||||
pub ip_salt: Pepper,
|
pub ip_salt: Pepper,
|
||||||
pub search_config: search::SearchConfig,
|
pub search_config: search::SearchConfig,
|
||||||
@@ -72,7 +73,7 @@ pub fn app_setup(
|
|||||||
search_config: search::SearchConfig,
|
search_config: search::SearchConfig,
|
||||||
clickhouse: &mut Client,
|
clickhouse: &mut Client,
|
||||||
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||||
maxmind: Arc<queue::maxmind::MaxMindIndexer>,
|
maxmind: MaxMind,
|
||||||
stripe_client: stripe::Client,
|
stripe_client: stripe::Client,
|
||||||
anrok_client: anrok::Client,
|
anrok_client: anrok::Client,
|
||||||
email_queue: EmailQueue,
|
email_queue: EmailQueue,
|
||||||
@@ -218,27 +219,6 @@ pub fn app_setup(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let reader = maxmind.clone();
|
|
||||||
{
|
|
||||||
let reader_ref = reader;
|
|
||||||
scheduler.run(Duration::from_secs(60 * 60 * 24), move || {
|
|
||||||
let reader_ref = reader_ref.clone();
|
|
||||||
|
|
||||||
async move {
|
|
||||||
info!("Downloading MaxMind GeoLite2 country database");
|
|
||||||
let result = reader_ref.index().await;
|
|
||||||
if let Err(e) = result {
|
|
||||||
warn!(
|
|
||||||
"Downloading MaxMind GeoLite2 country database failed: {:?}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
info!("Done downloading MaxMind GeoLite2 country database");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
info!("Downloading MaxMind GeoLite2 country database");
|
|
||||||
|
|
||||||
let analytics_queue = Arc::new(AnalyticsQueue::new());
|
let analytics_queue = Arc::new(AnalyticsQueue::new());
|
||||||
{
|
{
|
||||||
let client_ref = clickhouse.clone();
|
let client_ref = clickhouse.clone();
|
||||||
@@ -287,7 +267,7 @@ pub fn app_setup(
|
|||||||
redis_pool,
|
redis_pool,
|
||||||
clickhouse: clickhouse.clone(),
|
clickhouse: clickhouse.clone(),
|
||||||
file_host,
|
file_host,
|
||||||
maxmind,
|
maxmind: web::Data::new(maxmind),
|
||||||
scheduler: Arc::new(scheduler),
|
scheduler: Arc::new(scheduler),
|
||||||
ip_salt,
|
ip_salt,
|
||||||
search_config,
|
search_config,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use labrinth::search;
|
|||||||
use labrinth::util::anrok;
|
use labrinth::util::anrok;
|
||||||
use labrinth::util::env::parse_var;
|
use labrinth::util::env::parse_var;
|
||||||
use labrinth::util::ratelimit::rate_limit_middleware;
|
use labrinth::util::ratelimit::rate_limit_middleware;
|
||||||
use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue};
|
use labrinth::{check_env_vars, clickhouse, database, file_hosting};
|
||||||
use std::ffi::CStr;
|
use std::ffi::CStr;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -215,8 +215,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxmind_reader =
|
let maxmind_reader = modrinth_maxmind::MaxMind::new().await;
|
||||||
Arc::new(queue::maxmind::MaxMindIndexer::new().await.unwrap());
|
|
||||||
|
|
||||||
let prometheus = PrometheusMetricsBuilder::new("labrinth")
|
let prometheus = PrometheusMetricsBuilder::new("labrinth")
|
||||||
.endpoint("/metrics")
|
.endpoint("/metrics")
|
||||||
|
|||||||
@@ -1,87 +1,23 @@
|
|||||||
use flate2::read::GzDecoder;
|
use modrinth_maxmind::{MaxMind, geoip2};
|
||||||
use maxminddb::geoip2::Country;
|
|
||||||
use std::io::{Cursor, Read};
|
|
||||||
use std::net::Ipv6Addr;
|
use std::net::Ipv6Addr;
|
||||||
use tar::Archive;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
pub struct MaxMindIndexer {
|
pub struct MaxMindIndexer {
|
||||||
pub reader: RwLock<Option<maxminddb::Reader<Vec<u8>>>>,
|
pub maxmind: MaxMind,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MaxMindIndexer {
|
impl MaxMindIndexer {
|
||||||
pub async fn new() -> Result<Self, reqwest::Error> {
|
pub async fn new() -> Self {
|
||||||
let reader = MaxMindIndexer::inner_index(false).await.ok().flatten();
|
Self {
|
||||||
|
maxmind: MaxMind::new().await,
|
||||||
Ok(MaxMindIndexer {
|
|
||||||
reader: RwLock::new(reader),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn index(&self) -> Result<(), reqwest::Error> {
|
|
||||||
let reader = MaxMindIndexer::inner_index(false).await?;
|
|
||||||
|
|
||||||
if let Some(reader) = reader {
|
|
||||||
let mut reader_new = self.reader.write().await;
|
|
||||||
*reader_new = Some(reader);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn inner_index(
|
|
||||||
should_panic: bool,
|
|
||||||
) -> Result<Option<maxminddb::Reader<Vec<u8>>>, reqwest::Error> {
|
|
||||||
let response = reqwest::get(
|
|
||||||
format!(
|
|
||||||
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key={}&suffix=tar.gz",
|
|
||||||
dotenvy::var("MAXMIND_LICENSE_KEY").unwrap()
|
|
||||||
)
|
|
||||||
).await?.bytes().await.unwrap().to_vec();
|
|
||||||
|
|
||||||
let tarfile = GzDecoder::new(Cursor::new(response));
|
|
||||||
let mut archive = Archive::new(tarfile);
|
|
||||||
|
|
||||||
if let Ok(entries) = archive.entries() {
|
|
||||||
for mut file in entries.flatten() {
|
|
||||||
if let Ok(path) = file.header().path()
|
|
||||||
&& path.extension().and_then(|x| x.to_str()) == Some("mmdb")
|
|
||||||
{
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
file.read_to_end(&mut buf).unwrap();
|
|
||||||
|
|
||||||
let reader = maxminddb::Reader::from_source(buf).unwrap();
|
|
||||||
|
|
||||||
return Ok(Some(reader));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if should_panic {
|
|
||||||
panic!(
|
|
||||||
"Unable to download maxmind database- did you get a license key?"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
warn!("Unable to download maxmind database.");
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn query(&self, ip: Ipv6Addr) -> Option<String> {
|
pub async fn query(&self, ip: Ipv6Addr) -> Option<String> {
|
||||||
let maxmind = self.reader.read().await;
|
let reader = self.maxmind.reader.as_ref()?;
|
||||||
|
reader
|
||||||
if let Some(ref maxmind) = *maxmind {
|
.lookup::<geoip2::Country>(ip.into())
|
||||||
maxmind
|
.ok()?
|
||||||
.lookup::<Country>(ip.into())
|
.and_then(|c| c.country)
|
||||||
.ok()
|
.and_then(|c| c.iso_code.map(|s| s.to_string()))
|
||||||
.flatten()
|
|
||||||
.and_then(|x| {
|
|
||||||
x.country.and_then(|x| x.iso_code.map(|x| x.to_string()))
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use labrinth::queue::email::EmailQueue;
|
use labrinth::queue::email::EmailQueue;
|
||||||
use labrinth::util::anrok;
|
use labrinth::util::anrok;
|
||||||
use labrinth::{LabrinthConfig, file_hosting, queue};
|
use labrinth::{LabrinthConfig, file_hosting};
|
||||||
use labrinth::{check_env_vars, clickhouse};
|
use labrinth::{check_env_vars, clickhouse};
|
||||||
|
use modrinth_maxmind::MaxMind;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub mod api_common;
|
pub mod api_common;
|
||||||
@@ -37,8 +38,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
|||||||
Arc::new(file_hosting::MockHost::new());
|
Arc::new(file_hosting::MockHost::new());
|
||||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||||
|
|
||||||
let maxmind_reader =
|
let maxmind_reader = MaxMind::new().await;
|
||||||
Arc::new(queue::maxmind::MaxMindIndexer::new().await.unwrap());
|
|
||||||
|
|
||||||
let stripe_client =
|
let stripe_client =
|
||||||
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
||||||
|
|||||||
25
packages/modrinth-maxmind/Cargo.toml
Normal file
25
packages/modrinth-maxmind/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "modrinth-maxmind"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bytes = { workspace = true }
|
||||||
|
directories = { workspace = true }
|
||||||
|
eyre = { workspace = true }
|
||||||
|
flate2 = { workspace = true }
|
||||||
|
maxminddb = { workspace = true }
|
||||||
|
modrinth-util = { workspace = true }
|
||||||
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
|
tar = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["fs"] }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
clap = { workspace = true, features = ["derive"] }
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
1
packages/modrinth-maxmind/README.md
Normal file
1
packages/modrinth-maxmind/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Allows opening and reading a MaxMind GeoIP database, for use in an `actix-web` app.
|
||||||
35
packages/modrinth-maxmind/examples/maxmind.rs
Normal file
35
packages/modrinth-maxmind/examples/maxmind.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//! Example/testing binary for checking if a MaxMind database can be loaded from
|
||||||
|
//! the current environment.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use eyre::Result;
|
||||||
|
use maxminddb::geoip2;
|
||||||
|
use modrinth_util::Context;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Looks up country details for an IP using the MaxMind database
|
||||||
|
#[derive(Debug, clap::Parser)]
|
||||||
|
struct Args {
|
||||||
|
/// IP address to look up
|
||||||
|
ip: IpAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = <Args as clap::Parser>::parse();
|
||||||
|
tracing_subscriber::fmt().init();
|
||||||
|
|
||||||
|
let maxmind = modrinth_maxmind::init_reader()
|
||||||
|
.await
|
||||||
|
.wrap_err("failed to create reader")?;
|
||||||
|
|
||||||
|
let ip = args.ip;
|
||||||
|
let country = maxmind
|
||||||
|
.lookup::<geoip2::Country>(ip)
|
||||||
|
.wrap_err("failed to lookup country")?;
|
||||||
|
|
||||||
|
info!("Country details for {ip:?}:\n{country:#?}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
160
packages/modrinth-maxmind/src/lib.rs
Normal file
160
packages/modrinth-maxmind/src/lib.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
io::{Cursor, Read},
|
||||||
|
path::Path,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
pub use maxminddb::{self, geoip2};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use eyre::{Result, bail, eyre};
|
||||||
|
use modrinth_util::{Context, env_var};
|
||||||
|
use tokio::fs;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
/// MaxMind GeoIP database reader for use as a `web::Data` parameter.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MaxMind {
|
||||||
|
/// Database reader.
|
||||||
|
///
|
||||||
|
/// If the backend was not configured with MaxMind, the reader will not be
|
||||||
|
/// available.
|
||||||
|
pub reader: Option<Arc<maxminddb::Reader<Bytes>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaxMind {
|
||||||
|
/// Creates a [`MaxMind`] with no reader.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn none() -> Self {
|
||||||
|
Self { reader: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to create a [`MaxMind`] with a MaxMind GeoIP database reader.
|
||||||
|
///
|
||||||
|
/// This reads creation and download parameters from environment variables.
|
||||||
|
///
|
||||||
|
/// If the database could not be created or downloaded, this will make a
|
||||||
|
/// [`MaxMind`] with no reader.
|
||||||
|
pub async fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
reader: init_reader()
|
||||||
|
.await
|
||||||
|
.inspect_err(|err| {
|
||||||
|
warn!("Failed to initialize MaxMind: {err:#}");
|
||||||
|
})
|
||||||
|
.map(Arc::new)
|
||||||
|
.ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a [`maxminddb::Reader`] for use in [`MaxMind::reader`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Errors if the database is not present, or could not be downloaded (i.e.
|
||||||
|
/// missing license key).
|
||||||
|
pub async fn init_reader() -> Result<maxminddb::Reader<Bytes>> {
|
||||||
|
let db = if let Ok(db_path) = env_var("MAXMIND_DB") {
|
||||||
|
info!("Using MaxMind database at {db_path:?}");
|
||||||
|
|
||||||
|
fs::read(&db_path)
|
||||||
|
.await
|
||||||
|
.map(Bytes::from)
|
||||||
|
.wrap_err_with(|| {
|
||||||
|
eyre!("failed to read database from {db_path:?}")
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
let account_id = env_var("MAXMIND_ACCOUNT_ID")?;
|
||||||
|
let license_key = env_var("MAXMIND_LICENSE_KEY")?;
|
||||||
|
|
||||||
|
let dirs = directories::ProjectDirs::from(
|
||||||
|
"com.modrinth",
|
||||||
|
"Modrinth",
|
||||||
|
"modrinth-backend",
|
||||||
|
)
|
||||||
|
.wrap_err("failed to get cache directory")?;
|
||||||
|
let cache_dir = dirs.cache_dir();
|
||||||
|
let db_path = cache_dir.join("geolite.mmdb");
|
||||||
|
|
||||||
|
match fs::read(&db_path).await {
|
||||||
|
Ok(db) => {
|
||||||
|
info!("Using cached MaxMind database at {db_path:?}");
|
||||||
|
Bytes::from(db)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"Failed to read MaxMind database from {db_path:?}, will download: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let db = download(&account_id, &license_key).await?;
|
||||||
|
|
||||||
|
match write_to_cache(cache_dir, &db_path, &db).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("Wrote GeoIP database cache to {db_path:?}");
|
||||||
|
}
|
||||||
|
Err(err) => warn!(
|
||||||
|
"Failed to write GeoIP database cache to {db_path:?}: {err:?}",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Downloaded and cached database");
|
||||||
|
db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
maxminddb::Reader::from_source(db).wrap_err("failed to create reader")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(account_id: &str, license_key: &str) -> Result<Bytes> {
|
||||||
|
info!("Downloading MaxMind GeoIP database");
|
||||||
|
let db = reqwest::Client::new()
|
||||||
|
.get("https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz")
|
||||||
|
.basic_auth(account_id, Some(license_key))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.wrap_err("failed to begin downloading GeoIP database")?
|
||||||
|
.error_for_status()
|
||||||
|
.wrap_err("failed to download GeoIP database")?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.wrap_err("failed to finish downloading GeoIP database")?;
|
||||||
|
|
||||||
|
let db = GzDecoder::new(Cursor::new(db));
|
||||||
|
let mut archive = tar::Archive::new(db);
|
||||||
|
|
||||||
|
let entries = archive.entries().wrap_err("failed to read entries")?;
|
||||||
|
for entry in entries {
|
||||||
|
let mut entry = entry.wrap_err("failed to read entry")?;
|
||||||
|
let Ok(path) = entry.header().path() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if path.extension().and_then(|x| x.to_str()) == Some("mmdb") {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
entry
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.wrap_err("failed to read entry")?;
|
||||||
|
return Ok(Bytes::from(buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("no entries in archive");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_to_cache(
|
||||||
|
cache_dir: &Path,
|
||||||
|
db_path: &Path,
|
||||||
|
db: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
fs::create_dir_all(cache_dir)
|
||||||
|
.await
|
||||||
|
.wrap_err("failed to create parent directories")?;
|
||||||
|
fs::write(db_path, db)
|
||||||
|
.await
|
||||||
|
.wrap_err("failed to write to file")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
15
packages/modrinth-util/Cargo.toml
Normal file
15
packages/modrinth-util/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "modrinth-util"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-web = { workspace = true }
|
||||||
|
derive_more = { workspace = true, features = ["display", "error", "from"] }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
|
eyre = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
1
packages/modrinth-util/README.md
Normal file
1
packages/modrinth-util/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Modrinth services utilities.
|
||||||
326
packages/modrinth-util/src/error.rs
Normal file
326
packages/modrinth-util/src/error.rs
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
use std::{
|
||||||
|
convert::Infallible,
|
||||||
|
fmt::{Debug, Display},
|
||||||
|
};
|
||||||
|
|
||||||
|
use actix_web::{HttpResponse, ResponseError, http::StatusCode};
|
||||||
|
use derive_more::{Display, Error};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Error when calling an HTTP endpoint.
|
||||||
|
#[derive(Debug, Display, Error)]
|
||||||
|
pub enum ApiError {
|
||||||
|
/// Error occurred on the server side, which the caller has no fault in.
|
||||||
|
Internal(eyre::Report),
|
||||||
|
/// Caller made an invalid or malformed request.
|
||||||
|
Request(eyre::Report),
|
||||||
|
/// Caller attempted a request which they are not allowed to make.
|
||||||
|
Auth(eyre::Report),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseError for ApiError {
|
||||||
|
fn status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::Request(_) => StatusCode::BAD_REQUEST,
|
||||||
|
Self::Auth(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response(&self) -> HttpResponse {
|
||||||
|
HttpResponse::build(self.status_code()).json(ErrorResponse {
|
||||||
|
// internal error details are not leaked to the caller
|
||||||
|
description: match self {
|
||||||
|
Self::Internal(_) => None,
|
||||||
|
_ => Some(self.to_string()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an [`ApiError`] is represented when sending over an HTTP request.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
/// Text description of the error that occurred.
|
||||||
|
///
|
||||||
|
/// [`ApiError::Internal`] errors have their description filtered out, and
|
||||||
|
/// will hold [`None`].
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allows wrapping [`Result`]s and [`Option`]s into [`Result<T, ApiError>`]s.
|
||||||
|
#[allow(
|
||||||
|
clippy::missing_errors_doc,
|
||||||
|
reason = "this trait's purpose is improving error handling"
|
||||||
|
)]
|
||||||
|
pub trait Context<T, E>: Sized {
|
||||||
|
/// Maps the error variant into an [`eyre::Report`], creating the message
|
||||||
|
/// using `f`.
|
||||||
|
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static;
|
||||||
|
|
||||||
|
/// Maps the error variant into an [`eyre::Report`] with the given message.
|
||||||
|
/// Maps the error variant into an [`eyre::Report`] with the given message.
|
||||||
|
#[inline]
|
||||||
|
fn wrap_err<D>(self, msg: D) -> Result<T, eyre::Report>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.wrap_err_with(|| msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the error variant into an [`ApiError::Internal`] using the closure to create the message.
|
||||||
|
#[inline]
|
||||||
|
fn wrap_internal_err_with<D>(
|
||||||
|
self,
|
||||||
|
f: impl FnOnce() -> D,
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.wrap_err_with(f).map_err(ApiError::Internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the error variant into an [`ApiError::Internal`] with the given message.
|
||||||
|
#[inline]
|
||||||
|
fn wrap_internal_err<D>(self, msg: D) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.wrap_internal_err_with(|| msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the error variant into an [`ApiError::Request`] using the closure to create the message.
|
||||||
|
#[inline]
|
||||||
|
fn wrap_request_err_with<D>(
|
||||||
|
self,
|
||||||
|
f: impl FnOnce() -> D,
|
||||||
|
) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.wrap_err_with(f).map_err(ApiError::Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the error variant into an [`ApiError::Request`] with the given message.
|
||||||
|
#[inline]
|
||||||
|
fn wrap_request_err<D>(self, msg: D) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.wrap_request_err_with(|| msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the error variant into an [`ApiError::Auth`] using the closure to create the message.
|
||||||
|
#[inline]
|
||||||
|
fn wrap_auth_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.wrap_err_with(f).map_err(ApiError::Auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the error variant into an [`ApiError::Auth`] with the given message.
|
||||||
|
#[inline]
|
||||||
|
fn wrap_auth_err<D>(self, msg: D) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.wrap_auth_err_with(|| msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, E> Context<T, E> for Result<T, E>
|
||||||
|
where
|
||||||
|
Self: eyre::WrapErr<T, E>,
|
||||||
|
{
|
||||||
|
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
eyre::WrapErr::wrap_err_with(self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Context<T, Infallible> for Option<T> {
|
||||||
|
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||||
|
where
|
||||||
|
D: Send + Sync + Debug + Display + 'static,
|
||||||
|
{
|
||||||
|
self.ok_or_else(|| eyre::Report::msg(f()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// impl<T, E, Ty> Context<T, E> for Ty where Ty: eyre::WrapErr<T, E> {}
|
||||||
|
|
||||||
|
// impl<T, Ty> Context<T, Infallible> for Ty where Ty: eyre::OptionExt<T> {}
|
||||||
|
|
||||||
|
// impl<T, E> Context<T, E> for Result<T, E>
|
||||||
|
// where
|
||||||
|
// Self: eyre::WrapErr<T, E>,
|
||||||
|
// {
|
||||||
|
// fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||||
|
// where
|
||||||
|
// D: Send + Sync + Debug + Display + 'static,
|
||||||
|
// {
|
||||||
|
// self.map_err(|err| eyre::Report::new(err).wrap_err(f()))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// impl<T> Context<T, Infallible> for Option<T> {
|
||||||
|
// fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||||
|
// where
|
||||||
|
// D: Send + Sync + Debug + Display + 'static,
|
||||||
|
// {
|
||||||
|
// self.ok_or_else(|| eyre::Report::msg(f()))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_api_error_display() {
|
||||||
|
let error = ApiError::Internal(eyre::eyre!("test internal error"));
|
||||||
|
assert!(error.to_string().contains("test internal error"));
|
||||||
|
|
||||||
|
let error = ApiError::Request(eyre::eyre!("test request error"));
|
||||||
|
assert!(error.to_string().contains("test request error"));
|
||||||
|
|
||||||
|
let error = ApiError::Auth(eyre::eyre!("test auth error"));
|
||||||
|
assert!(error.to_string().contains("test auth error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_api_error_debug() {
|
||||||
|
let error = ApiError::Internal(eyre::eyre!("test error"));
|
||||||
|
let debug_str = format!("{error:?}");
|
||||||
|
assert!(debug_str.contains("Internal"));
|
||||||
|
assert!(debug_str.contains("test error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_error_status_codes() {
|
||||||
|
let internal_error = ApiError::Internal(eyre::eyre!("internal error"));
|
||||||
|
assert_eq!(
|
||||||
|
internal_error.status_code(),
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_error = ApiError::Request(eyre::eyre!("request error"));
|
||||||
|
assert_eq!(request_error.status_code(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
let auth_error = ApiError::Auth(eyre::eyre!("auth error"));
|
||||||
|
assert_eq!(auth_error.status_code(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_error_response() {
|
||||||
|
let error = ApiError::Request(eyre::eyre!("test request error"));
|
||||||
|
let response = error.error_response();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Skip the body parsing test as it requires async and is more complex
|
||||||
|
// The important thing is that the error response is created correctly
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_trait_result() {
|
||||||
|
let result: Result<i32, std::io::Error> = Ok(42);
|
||||||
|
let wrapped = result.wrap_err("context message");
|
||||||
|
assert_eq!(wrapped.unwrap(), 42);
|
||||||
|
|
||||||
|
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"not found",
|
||||||
|
));
|
||||||
|
let wrapped = result.wrap_err("context message");
|
||||||
|
assert!(wrapped.is_err());
|
||||||
|
assert!(wrapped.unwrap_err().to_string().contains("context message"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_trait_option() {
|
||||||
|
let option: Option<i32> = Some(42);
|
||||||
|
let wrapped = option.wrap_err("context message");
|
||||||
|
assert_eq!(wrapped.unwrap(), 42);
|
||||||
|
|
||||||
|
let option: Option<i32> = None;
|
||||||
|
let wrapped = option.wrap_err("context message");
|
||||||
|
assert!(wrapped.is_err());
|
||||||
|
assert_eq!(wrapped.unwrap_err().to_string(), "context message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_trait_internal_error() {
|
||||||
|
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"not found",
|
||||||
|
));
|
||||||
|
let wrapped = result.wrap_internal_err("internal error context");
|
||||||
|
|
||||||
|
assert!(wrapped.is_err());
|
||||||
|
match wrapped.unwrap_err() {
|
||||||
|
ApiError::Internal(report) => {
|
||||||
|
assert!(report.to_string().contains("internal error context"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Internal error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_trait_request_error() {
|
||||||
|
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"not found",
|
||||||
|
));
|
||||||
|
let wrapped = result.wrap_request_err("request error context");
|
||||||
|
|
||||||
|
assert!(wrapped.is_err());
|
||||||
|
match wrapped.unwrap_err() {
|
||||||
|
ApiError::Request(report) => {
|
||||||
|
assert!(report.to_string().contains("request error context"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Request error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_trait_auth_error() {
|
||||||
|
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"not found",
|
||||||
|
));
|
||||||
|
let wrapped = result.wrap_auth_err("auth error context");
|
||||||
|
|
||||||
|
assert!(wrapped.is_err());
|
||||||
|
match wrapped.unwrap_err() {
|
||||||
|
ApiError::Auth(report) => {
|
||||||
|
assert!(report.to_string().contains("auth error context"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Auth error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_trait_with_closure() {
|
||||||
|
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"not found",
|
||||||
|
));
|
||||||
|
let wrapped =
|
||||||
|
result.wrap_err_with(|| format!("context with {}", "dynamic"));
|
||||||
|
|
||||||
|
assert!(wrapped.is_err());
|
||||||
|
assert!(
|
||||||
|
wrapped
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("context with dynamic")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/modrinth-util/src/lib.rs
Normal file
23
packages/modrinth-util/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
pub use error::*;
|
||||||
|
|
||||||
|
use eyre::{Result, eyre};
|
||||||
|
|
||||||
|
/// Fetches an environment variable, possibly loading it using [`dotenvy`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Errors if the environment variable is missing or empty, providing a
|
||||||
|
/// pretty-printed error including the environment variable name.
|
||||||
|
#[track_caller]
|
||||||
|
pub fn env_var(key: &str) -> Result<String> {
|
||||||
|
let value = dotenvy::var(key)
|
||||||
|
.wrap_err_with(|| eyre!("missing environment variable `{key}`"))?;
|
||||||
|
if value.is_empty() {
|
||||||
|
Err(eyre!("environment variable `{key}` is empty"))
|
||||||
|
} else {
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user