mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
feat: email bounce check (#6774)
* feat: neverbounce API client * feat(labrinth): integrate neverbounce * chore: tombi * chore: typo * chore: clippy * chore: remove unused dep * chore: cleanup
This commit is contained in:
@@ -79,6 +79,7 @@ modrinth-content-management = { workspace = true }
|
||||
modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] }
|
||||
muralpay = { workspace = true, features = ["client", "mock", "utoipa"] }
|
||||
murmur2 = { workspace = true }
|
||||
neverbounce = { workspace = true }
|
||||
paste = { workspace = true }
|
||||
path-util = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
|
||||
@@ -235,6 +235,9 @@ vars! {
|
||||
SENDY_LIST_ID: String = "none";
|
||||
SENDY_API_KEY: String = "none";
|
||||
|
||||
NEVERBOUNCE_API_KEY: String = "";
|
||||
NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL;
|
||||
|
||||
CLICKHOUSE_REPLICATED: bool = false;
|
||||
CLICKHOUSE_URL: String = "http://localhost:8123";
|
||||
CLICKHOUSE_USER: String = "default";
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::util::captcha::check_hcaptcha;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::ext::get_image_ext;
|
||||
use crate::util::img::upload_image_optimized;
|
||||
use crate::util::neverbounce::{check_email, email_check_error_generic};
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_http::header::LOCATION;
|
||||
use actix_web::http::StatusCode;
|
||||
@@ -1453,7 +1454,7 @@ fn validate_account_consent(account_consent: bool) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create account with OAuth.
|
||||
/// Create account with OAuth.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -1496,6 +1497,10 @@ pub async fn create_oauth_account(
|
||||
return Err(ApiError::Internal(eyre!("invalid flow kind")));
|
||||
};
|
||||
|
||||
if let Some(email) = &user.email {
|
||||
ensure_email_is_usable(email).await?;
|
||||
}
|
||||
|
||||
let mut txn = db
|
||||
.begin()
|
||||
.await
|
||||
@@ -1542,7 +1547,7 @@ struct DiscordCommunityHandoffPayload {
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
/// Link Discord community.
|
||||
/// Link Discord community.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -1619,7 +1624,7 @@ pub async fn discord_community_link(
|
||||
Ok(web::Json(DiscordCommunityLinkResponse { url }))
|
||||
}
|
||||
|
||||
/// Remove an auth provider.
|
||||
/// Remove an auth provider.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -1811,6 +1816,20 @@ impl From<NewAccount> for AccountRegisterFlow {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
|
||||
let result = check_email(email).await.map_err(ApiError::Request)?;
|
||||
|
||||
if matches!(
|
||||
result,
|
||||
neverbounce::VerificationResult::Invalid
|
||||
| neverbounce::VerificationResult::Disposable
|
||||
) {
|
||||
return Err(ApiError::Request(email_check_error_generic()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl AccountRegisterFlow {
|
||||
async fn validate(
|
||||
self,
|
||||
@@ -1964,7 +1983,7 @@ impl ReadyAccountRegisterFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate password account creation.
|
||||
/// Validate password account creation.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -1992,7 +2011,7 @@ pub async fn validate_create_account_with_password(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create account with a password.
|
||||
/// Create account with a password.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2021,6 +2040,8 @@ pub async fn create_account_with_password(
|
||||
return Err(ApiError::Turnstile);
|
||||
}
|
||||
|
||||
ensure_email_is_usable(&new_account.email).await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let ready_flow = AccountRegisterFlow::from(new_account)
|
||||
@@ -2043,7 +2064,7 @@ pub struct Login {
|
||||
pub challenge: String,
|
||||
}
|
||||
|
||||
/// Log in with a password.
|
||||
/// Log in with a password.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2204,7 +2225,7 @@ async fn validate_2fa_code(
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete login with 2FA.
|
||||
/// Complete login with 2FA.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2264,7 +2285,7 @@ pub async fn login_2fa(
|
||||
}
|
||||
}
|
||||
|
||||
/// Start 2FA setup.
|
||||
/// Start 2FA setup.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2315,7 +2336,7 @@ pub async fn begin_2fa_flow(
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish 2FA setup.
|
||||
/// Finish 2FA setup.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2450,7 +2471,7 @@ pub struct Remove2FA {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Remove 2FA.
|
||||
/// Remove 2FA.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2550,7 +2571,7 @@ pub struct ResetPassword {
|
||||
pub challenge: String,
|
||||
}
|
||||
|
||||
/// Start password reset.
|
||||
/// Start password reset.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2656,7 +2677,7 @@ pub struct ChangePassword {
|
||||
pub new_password: Option<String>,
|
||||
}
|
||||
|
||||
/// Change password.
|
||||
/// Change password.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2822,7 +2843,7 @@ pub struct SetEmail {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Set email address.
|
||||
/// Set email address.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -2875,6 +2896,8 @@ pub async fn set_email(
|
||||
));
|
||||
}
|
||||
|
||||
ensure_email_is_usable(&email_address.email).await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -2944,7 +2967,7 @@ pub async fn set_email(
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
/// Resend verification email.
|
||||
/// Resend verification email.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3019,7 +3042,7 @@ pub struct VerifyEmail {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
/// Verify email address.
|
||||
/// Verify email address.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3085,7 +3108,7 @@ pub async fn verify_email(
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the newsletter.
|
||||
/// Subscribe to the newsletter.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3134,7 +3157,7 @@ pub async fn subscribe_newsletter(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Get newsletter subscription status.
|
||||
/// Get newsletter subscription status.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3184,7 +3207,7 @@ pub struct RegisterPasskeyResponse {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
/// Start passkey registration.
|
||||
/// Start passkey registration.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3284,7 +3307,7 @@ pub struct PasskeyResponse {
|
||||
pub last_used: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Finish passkey registration.
|
||||
/// Finish passkey registration.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3393,7 +3416,7 @@ pub struct AuthenticatePasskeyResponse {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
/// Start passkey authentication.
|
||||
/// Start passkey authentication.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3436,7 +3459,7 @@ pub struct AuthenticatePasskeyFinish {
|
||||
pub credential: PublicKeyCredential,
|
||||
}
|
||||
|
||||
/// Finish passkey authentication.
|
||||
/// Finish passkey authentication.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3553,7 +3576,7 @@ pub async fn authenticate_passkey_finish(
|
||||
}
|
||||
}
|
||||
|
||||
/// List passkeys.
|
||||
/// List passkeys.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3603,7 +3626,7 @@ pub struct RenamePasskey {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Rename a passkey.
|
||||
/// Rename a passkey.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
@@ -3658,7 +3681,7 @@ pub async fn rename_passkey(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Delete a passkey.
|
||||
/// Delete a passkey.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod http;
|
||||
pub mod img;
|
||||
pub mod ip;
|
||||
pub mod kafka;
|
||||
pub mod neverbounce;
|
||||
pub mod ratelimit;
|
||||
pub mod redis;
|
||||
pub mod routes;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use eyre::{WrapErr, eyre};
|
||||
use neverbounce::{
|
||||
ResponseStatus, SingleCheckParams, SingleCheckResponse, VerificationResult,
|
||||
};
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::env::ENV;
|
||||
use crate::util::http::HTTP_CLIENT;
|
||||
|
||||
pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
|
||||
if ENV.NEVERBOUNCE_API_KEY.is_empty() {
|
||||
debug!(
|
||||
result = "unknown",
|
||||
"NeverBounce email check skipped because API key is not set",
|
||||
);
|
||||
return Ok(VerificationResult::Unknown);
|
||||
}
|
||||
|
||||
let params = SingleCheckParams::new(&ENV.NEVERBOUNCE_API_KEY, email)
|
||||
.with_api_url(&ENV.NEVERBOUNCE_BASE_URL);
|
||||
|
||||
let check_time_start = Instant::now();
|
||||
|
||||
let response = match neverbounce::single_check(&HTTP_CLIENT, ¶ms).await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(source) => {
|
||||
error!(
|
||||
result = "unknown",
|
||||
error = ?source,
|
||||
"NeverBounce email check failed",
|
||||
);
|
||||
return Err(eyre!(source)).wrap_err("failed to check email");
|
||||
}
|
||||
};
|
||||
|
||||
let SingleCheckResponse { status, result, .. } = response;
|
||||
|
||||
let check_time = check_time_start.elapsed();
|
||||
|
||||
match status {
|
||||
ResponseStatus::Success => {
|
||||
let result = result.ok_or_else(|| {
|
||||
error!(result = "unknown", "NeverBounce email check failed",);
|
||||
eyre!("")
|
||||
})?;
|
||||
|
||||
if matches!(result, VerificationResult::Unrecognized(_)) {
|
||||
error!(
|
||||
result = result.as_str(),
|
||||
request.time_ms = check_time.as_millis(),
|
||||
"NeverBounce email check failed",
|
||||
);
|
||||
return Err(email_check_error_generic());
|
||||
}
|
||||
|
||||
debug!(
|
||||
result = result.as_str(),
|
||||
request.time_ms = check_time.as_millis(),
|
||||
"NeverBounce email check succeeded",
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
failure_type => {
|
||||
let result = result.unwrap_or(VerificationResult::Unknown);
|
||||
error!(
|
||||
failure_type = response_failure_type(&failure_type),
|
||||
result = result.as_str(),
|
||||
request.time_ms = check_time.as_millis(),
|
||||
"NeverBounce email check failed",
|
||||
);
|
||||
Err(email_check_error_generic())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email_check_error_generic() -> eyre::Error {
|
||||
eyre!("Please try a different email address!")
|
||||
}
|
||||
|
||||
fn response_failure_type(status: &ResponseStatus) -> &str {
|
||||
match status {
|
||||
ResponseStatus::Success => "success",
|
||||
ResponseStatus::GeneralFailure => "general_failure",
|
||||
ResponseStatus::AuthFailure => "auth_failure",
|
||||
ResponseStatus::TemporarilyUnavailable => "temp_unavail",
|
||||
ResponseStatus::ThrottleTriggered => "throttle_triggered",
|
||||
ResponseStatus::BadReferrer => "bad_referrer",
|
||||
ResponseStatus::Unrecognized(status) => status,
|
||||
_ => "unrecognized",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user