Use new MaxMind env vars on Labrinth (#4573)

* Bring in modrinth-maxmind

* integrate modrinth-maxmind into labrinth

* Fix CI
This commit is contained in:
aecsocket
2025-10-18 11:38:19 -07:00
committed by GitHub
parent d1ffed564d
commit fa7d1d7942
17 changed files with 655 additions and 111 deletions

View 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

View File

@@ -0,0 +1 @@
Allows opening and reading a MaxMind GeoIP database, for use in an `actix-web` app.

View 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(())
}

View 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(())
}

View 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

View File

@@ -0,0 +1 @@
Modrinth services utilities.

View 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")
);
}
}

View 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)
}
}