mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
feat: add redis cache to usercheck/nb responses (#7316)
* feat: add redis cache to usercheck responses * feat: add redis cache to neverbounce responses
This commit is contained in:
@@ -1544,7 +1544,7 @@ pub async fn create_oauth_account(
|
||||
};
|
||||
|
||||
if let Some(email) = &user.email {
|
||||
ensure_email_passes_gate(email)
|
||||
ensure_email_passes_gate(&redis, email)
|
||||
.await
|
||||
.wrap_api_err("validating email passes the signup gate")?;
|
||||
}
|
||||
@@ -1902,8 +1902,11 @@ impl From<NewAccount> for AccountRegisterFlow {
|
||||
}
|
||||
|
||||
/// Runs the UserCheck gate, which covers both password and OAuth signups.
|
||||
async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> {
|
||||
let action = check_email_gate(email)
|
||||
async fn ensure_email_passes_gate(
|
||||
redis: &RedisPool,
|
||||
email: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
let action = check_email_gate(redis, email)
|
||||
.await
|
||||
.wrap_request_err("checking email address")?;
|
||||
|
||||
@@ -1914,10 +1917,13 @@ async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
|
||||
ensure_email_passes_gate(email).await?;
|
||||
async fn ensure_email_is_usable(
|
||||
redis: &RedisPool,
|
||||
email: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
ensure_email_passes_gate(redis, email).await?;
|
||||
|
||||
let result = check_email(email)
|
||||
let result = check_email(redis, email)
|
||||
.await
|
||||
.wrap_request_err("checking email address")?;
|
||||
|
||||
@@ -2144,7 +2150,7 @@ pub async fn create_account_with_password(
|
||||
)));
|
||||
}
|
||||
|
||||
ensure_email_is_usable(&new_account.email)
|
||||
ensure_email_is_usable(&redis, &new_account.email)
|
||||
.await
|
||||
.wrap_api_err("validating email is usable")?;
|
||||
|
||||
@@ -3129,7 +3135,7 @@ pub async fn set_email(
|
||||
)));
|
||||
}
|
||||
|
||||
ensure_email_is_usable(&email_address.email)
|
||||
ensure_email_is_usable(&redis, &email_address.email)
|
||||
.await
|
||||
.wrap_api_err("validating email is usable")?;
|
||||
|
||||
|
||||
@@ -6,11 +6,22 @@ use neverbounce::{
|
||||
VerificationResult,
|
||||
};
|
||||
use tracing::{debug, error};
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::env::ENV;
|
||||
use crate::util::http::HTTP_CLIENT;
|
||||
|
||||
pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
|
||||
const CACHE_NAMESPACE: &str = "neverbounce:v1";
|
||||
const CACHE_EXPIRY_SECONDS: i64 = 60 * 60;
|
||||
|
||||
/// Verdicts are cached in Redis for an hour, keyed by address. Only verdicts
|
||||
/// NeverBounce actually returned are cached; the `Unknown` we fall back to when
|
||||
/// the API is unreachable is not, so an outage cannot pin an address for an
|
||||
/// hour.
|
||||
pub async fn check_email(
|
||||
redis: &RedisPool,
|
||||
email: &str,
|
||||
) -> eyre::Result<VerificationResult> {
|
||||
if ENV.NEVERBOUNCE_API_KEY.is_empty() {
|
||||
debug!(
|
||||
result = "unknown",
|
||||
@@ -19,6 +30,26 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
|
||||
return Ok(VerificationResult::Unknown);
|
||||
}
|
||||
|
||||
let cache_key = {
|
||||
let mut redis = redis.connect().await?;
|
||||
let key = redis
|
||||
.key()
|
||||
.entity(CACHE_NAMESPACE, email.to_ascii_lowercase());
|
||||
|
||||
if let Some(cached) = redis.get(&key).await? {
|
||||
let result = VerificationResult::from_api_value(&cached);
|
||||
|
||||
debug!(
|
||||
result = result.as_str(),
|
||||
"NeverBounce email check served from cache",
|
||||
);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
key
|
||||
};
|
||||
|
||||
let params = SingleCheckParams::new(&ENV.NEVERBOUNCE_API_KEY, email)
|
||||
.with_api_url(&ENV.NEVERBOUNCE_BASE_URL);
|
||||
|
||||
@@ -72,6 +103,13 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
|
||||
request.time_ms = check_time.as_millis(),
|
||||
"NeverBounce email check succeeded",
|
||||
);
|
||||
|
||||
redis
|
||||
.connect()
|
||||
.await?
|
||||
.set(&cache_key, result.as_str(), Some(CACHE_EXPIRY_SECONDS))
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
failure_type => {
|
||||
|
||||
@@ -5,12 +5,16 @@ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::env::ENV;
|
||||
use crate::util::http::HTTP_CLIENT;
|
||||
use crate::util::neverbounce::email_check_error_generic;
|
||||
|
||||
pub const DEFAULT_API_URL: &str = "https://api.usercheck.com";
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const CACHE_NAMESPACE: &str = "usercheck_gate:v1";
|
||||
const CACHE_EXPIRY_SECONDS: i64 = 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DecisionAction {
|
||||
@@ -21,6 +25,15 @@ pub enum DecisionAction {
|
||||
}
|
||||
|
||||
impl DecisionAction {
|
||||
fn from_api_value(value: &str) -> Self {
|
||||
match value {
|
||||
"allow" => Self::Allow,
|
||||
"block" => Self::Block,
|
||||
"challenge" => Self::Challenge,
|
||||
value => Self::Unrecognized(value.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
match self {
|
||||
DecisionAction::Allow => "allow",
|
||||
@@ -36,12 +49,7 @@ impl<'de> Deserialize<'de> for DecisionAction {
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(match String::deserialize(deserializer)?.as_str() {
|
||||
"allow" => Self::Allow,
|
||||
"block" => Self::Block,
|
||||
"challenge" => Self::Challenge,
|
||||
value => Self::Unrecognized(value.to_owned()),
|
||||
})
|
||||
Ok(Self::from_api_value(&String::deserialize(deserializer)?))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +93,12 @@ struct DecisionRequest<'a> {
|
||||
/// signup, while anything else is an error that rejects the signup.
|
||||
/// `Challenge` resolves to `Allow` because these flows have no step-up
|
||||
/// mechanism past the captcha that already ran.
|
||||
pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> {
|
||||
///
|
||||
/// Verdicts are cached in Redis for an hour, keyed by email address.
|
||||
pub async fn check_email_gate(
|
||||
redis: &RedisPool,
|
||||
email: &str,
|
||||
) -> eyre::Result<DecisionAction> {
|
||||
if ENV.USERCHECK_API_KEY.is_empty() || ENV.USERCHECK_GATE_ID.is_empty() {
|
||||
debug!(
|
||||
action = "allow",
|
||||
@@ -94,6 +107,26 @@ pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> {
|
||||
return Ok(DecisionAction::Allow);
|
||||
}
|
||||
|
||||
let cache_key = {
|
||||
let mut redis = redis.connect().await?;
|
||||
let key = redis
|
||||
.key()
|
||||
.entity(CACHE_NAMESPACE, email.to_ascii_lowercase());
|
||||
|
||||
if let Some(cached) = redis.get(&key).await? {
|
||||
let action = DecisionAction::from_api_value(&cached);
|
||||
|
||||
debug!(
|
||||
action = action.as_str(),
|
||||
"UserCheck gate decision served from cache",
|
||||
);
|
||||
|
||||
return Ok(action);
|
||||
}
|
||||
|
||||
key
|
||||
};
|
||||
|
||||
let decision_time_start = Instant::now();
|
||||
let response = request_decision(email).await;
|
||||
let decision_time = decision_time_start.elapsed();
|
||||
@@ -166,6 +199,12 @@ pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> {
|
||||
),
|
||||
}
|
||||
|
||||
redis
|
||||
.connect()
|
||||
.await?
|
||||
.set(&cache_key, action.as_str(), Some(CACHE_EXPIRY_SECONDS))
|
||||
.await?;
|
||||
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user