mirror of
https://github.com/modrinth/code.git
synced 2026-09-05 06:19:11 +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 {
|
if let Some(email) = &user.email {
|
||||||
ensure_email_passes_gate(email)
|
ensure_email_passes_gate(&redis, email)
|
||||||
.await
|
.await
|
||||||
.wrap_api_err("validating email passes the signup gate")?;
|
.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.
|
/// Runs the UserCheck gate, which covers both password and OAuth signups.
|
||||||
async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> {
|
async fn ensure_email_passes_gate(
|
||||||
let action = check_email_gate(email)
|
redis: &RedisPool,
|
||||||
|
email: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let action = check_email_gate(redis, email)
|
||||||
.await
|
.await
|
||||||
.wrap_request_err("checking email address")?;
|
.wrap_request_err("checking email address")?;
|
||||||
|
|
||||||
@@ -1914,10 +1917,13 @@ async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
|
async fn ensure_email_is_usable(
|
||||||
ensure_email_passes_gate(email).await?;
|
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
|
.await
|
||||||
.wrap_request_err("checking email address")?;
|
.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
|
.await
|
||||||
.wrap_api_err("validating email is usable")?;
|
.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
|
.await
|
||||||
.wrap_api_err("validating email is usable")?;
|
.wrap_api_err("validating email is usable")?;
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,22 @@ use neverbounce::{
|
|||||||
VerificationResult,
|
VerificationResult,
|
||||||
};
|
};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
use xredis::RedisPool;
|
||||||
|
|
||||||
use crate::env::ENV;
|
use crate::env::ENV;
|
||||||
use crate::util::http::HTTP_CLIENT;
|
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() {
|
if ENV.NEVERBOUNCE_API_KEY.is_empty() {
|
||||||
debug!(
|
debug!(
|
||||||
result = "unknown",
|
result = "unknown",
|
||||||
@@ -19,6 +30,26 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
|
|||||||
return Ok(VerificationResult::Unknown);
|
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)
|
let params = SingleCheckParams::new(&ENV.NEVERBOUNCE_API_KEY, email)
|
||||||
.with_api_url(&ENV.NEVERBOUNCE_BASE_URL);
|
.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(),
|
request.time_ms = check_time.as_millis(),
|
||||||
"NeverBounce email check succeeded",
|
"NeverBounce email check succeeded",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redis
|
||||||
|
.connect()
|
||||||
|
.await?
|
||||||
|
.set(&cache_key, result.as_str(), Some(CACHE_EXPIRY_SECONDS))
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
failure_type => {
|
failure_type => {
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ use reqwest::StatusCode;
|
|||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
|
use xredis::RedisPool;
|
||||||
|
|
||||||
use crate::env::ENV;
|
use crate::env::ENV;
|
||||||
use crate::util::http::HTTP_CLIENT;
|
use crate::util::http::HTTP_CLIENT;
|
||||||
use crate::util::neverbounce::email_check_error_generic;
|
use crate::util::neverbounce::email_check_error_generic;
|
||||||
|
|
||||||
pub const DEFAULT_API_URL: &str = "https://api.usercheck.com";
|
pub const DEFAULT_API_URL: &str = "https://api.usercheck.com";
|
||||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum DecisionAction {
|
pub enum DecisionAction {
|
||||||
@@ -21,6 +25,15 @@ pub enum DecisionAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
DecisionAction::Allow => "allow",
|
DecisionAction::Allow => "allow",
|
||||||
@@ -36,12 +49,7 @@ impl<'de> Deserialize<'de> for DecisionAction {
|
|||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
Ok(match String::deserialize(deserializer)?.as_str() {
|
Ok(Self::from_api_value(&String::deserialize(deserializer)?))
|
||||||
"allow" => Self::Allow,
|
|
||||||
"block" => Self::Block,
|
|
||||||
"challenge" => Self::Challenge,
|
|
||||||
value => Self::Unrecognized(value.to_owned()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +93,12 @@ struct DecisionRequest<'a> {
|
|||||||
/// signup, while anything else is an error that rejects the signup.
|
/// signup, while anything else is an error that rejects the signup.
|
||||||
/// `Challenge` resolves to `Allow` because these flows have no step-up
|
/// `Challenge` resolves to `Allow` because these flows have no step-up
|
||||||
/// mechanism past the captcha that already ran.
|
/// 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() {
|
if ENV.USERCHECK_API_KEY.is_empty() || ENV.USERCHECK_GATE_ID.is_empty() {
|
||||||
debug!(
|
debug!(
|
||||||
action = "allow",
|
action = "allow",
|
||||||
@@ -94,6 +107,26 @@ pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> {
|
|||||||
return Ok(DecisionAction::Allow);
|
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 decision_time_start = Instant::now();
|
||||||
let response = request_decision(email).await;
|
let response = request_decision(email).await;
|
||||||
let decision_time = decision_time_start.elapsed();
|
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)
|
Ok(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,18 @@ pub enum VerificationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VerificationResult {
|
impl VerificationResult {
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_api_value(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"valid" => Self::Valid,
|
||||||
|
"invalid" => Self::Invalid,
|
||||||
|
"disposable" => Self::Disposable,
|
||||||
|
"catchall" => Self::CatchAll,
|
||||||
|
"unknown" => Self::Unknown,
|
||||||
|
value => Self::Unrecognized(value.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
VerificationResult::Valid => "valid",
|
VerificationResult::Valid => "valid",
|
||||||
@@ -182,14 +194,7 @@ impl<'de> Deserialize<'de> for VerificationResult {
|
|||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
Ok(match String::deserialize(deserializer)?.as_str() {
|
Ok(Self::from_api_value(&String::deserialize(deserializer)?))
|
||||||
"valid" => Self::Valid,
|
|
||||||
"invalid" => Self::Invalid,
|
|
||||||
"disposable" => Self::Disposable,
|
|
||||||
"catchall" => Self::CatchAll,
|
|
||||||
"unknown" => Self::Unknown,
|
|
||||||
value => Self::Unrecognized(value.to_owned()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user