Merge branch 'main' into boris/dev-1126-v2

This commit is contained in:
aecsocket
2026-08-26 14:39:18 +01:00
810 changed files with 59125 additions and 21252 deletions
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"TextArray",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"TextArray",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT preferences AS \"preferences: Json<PartialUserPreferences>\"\n FROM user_preferences\n WHERE user_id = $1\n FOR UPDATE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "preferences: Json<PartialUserPreferences>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT preferences AS \"preferences: Json<PartialUserPreferences>\"\n FROM user_preferences\n WHERE user_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "preferences: Json<PartialUserPreferences>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_preferences (user_id, preferences)\n VALUES ($1, $2)\n ON CONFLICT (user_id) DO UPDATE\n SET preferences = EXCLUDED.preferences\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Jsonb"
]
},
"nullable": []
},
"hash": "b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n AND EXISTS (\n SELECT 1 FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.mod_id = mods.id AND pt.name = $3\n -- prevents decorrelation, so this stops at the first match instead\n -- of scanning all versions before the outer sort/limit applies\n OFFSET 0\n )\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"TextArray",
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840"
}
+6 -27
View File
@@ -23,12 +23,7 @@ aditude = { workspace = true }
argon2 = { workspace = true }
ariadne = { workspace = true }
async-minecraft-ping = { workspace = true, features = ["srv"] }
async-stripe = { workspace = true, features = [
"billing",
"checkout",
"connect",
"webhook-events"
] }
async-stripe = { workspace = true, features = ["billing", "checkout", "connect", "webhook-events"] }
async-trait = { workspace = true }
aws-sdk-s3 = { workspace = true }
base64 = { workspace = true }
@@ -41,6 +36,7 @@ clap = { workspace = true, features = ["derive"] }
clickhouse = { workspace = true, features = ["time", "uuid"] }
color-eyre = { workspace = true }
color-thief = { workspace = true }
component-derive = { workspace = true }
const_format = { workspace = true }
dashmap = { workspace = true }
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
@@ -87,24 +83,10 @@ quick-xml = { workspace = true }
rand = { workspace = true }
rand_chacha = { workspace = true }
rdkafka = { workspace = true }
redis = { workspace = true, features = [
"ahash",
"cluster",
"cluster-async",
"r2d2",
"tokio-comp"
] }
redis = { workspace = true, features = ["ahash", "cluster", "cluster-async", "r2d2", "tokio-comp"] }
regex = { workspace = true }
reqwest = { workspace = true, features = [
"http2",
"json",
"multipart",
"rustls-tls-webpki-roots"
] }
rust_decimal = { workspace = true, features = [
"serde-with-float",
"serde-with-str"
] }
reqwest = { workspace = true, features = ["http2", "json", "multipart", "rustls-tls-webpki-roots"] }
rust_decimal = { workspace = true, features = ["serde-with-float", "serde-with-str"] }
rust_iso3166 = { workspace = true }
rustls.workspace = true
rusty-money = { workspace = true }
@@ -141,10 +123,7 @@ urlencoding = { workspace = true }
utoipa = { workspace = true, features = ["url"] }
uuid = { workspace = true, features = ["fast-rng", "serde", "v4", "v7"] }
validator = { workspace = true, features = ["derive"] }
webauthn-rs = { workspace = true, features = [
"conditional-ui",
"danger-allow-state-serialisation"
] }
webauthn-rs = { workspace = true, features = ["conditional-ui", "danger-allow-state-serialisation"] }
webauthn-rs-proto = { workspace = true }
webp = { workspace = true }
woothee = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
CREATE TABLE user_preferences (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferences JSONB NOT NULL
);
+1 -1
View File
@@ -100,7 +100,7 @@ impl BackgroundTask {
}
IncrementalIndexSearch => {
crate::search::incremental::consume::run(
pool,
ro_pool,
redis_pool,
search_backend,
kafka_client,
+1
View File
@@ -41,6 +41,7 @@ pub mod team_item;
pub mod thread_item;
pub mod user_item;
pub mod user_limits;
pub mod user_preferences_item;
pub mod user_subscription_item;
pub mod users_compliance;
pub mod users_notifications_preferences_item;
@@ -0,0 +1,75 @@
use crate::database::Executor;
use crate::database::models::DBUserId;
use crate::models::v3::preferences::PartialUserPreferences;
use sqlx::types::Json;
pub struct DBUserPreferences;
impl DBUserPreferences {
pub async fn get<'a, E>(
user_id: DBUserId,
exec: E,
) -> Result<Option<PartialUserPreferences>, sqlx::Error>
where
E: Executor<'a, Database = sqlx::Postgres>,
{
let row = sqlx::query!(
r#"
SELECT preferences AS "preferences: Json<PartialUserPreferences>"
FROM user_preferences
WHERE user_id = $1
"#,
user_id.0,
)
.fetch_optional(exec)
.await?;
Ok(row.map(|row| row.preferences.0))
}
pub async fn get_for_update<'a, E>(
user_id: DBUserId,
exec: E,
) -> Result<Option<PartialUserPreferences>, sqlx::Error>
where
E: Executor<'a, Database = sqlx::Postgres>,
{
let row = sqlx::query!(
r#"
SELECT preferences AS "preferences: Json<PartialUserPreferences>"
FROM user_preferences
WHERE user_id = $1
FOR UPDATE
"#,
user_id.0,
)
.fetch_optional(exec)
.await?;
Ok(row.map(|row| row.preferences.0))
}
pub async fn upsert<'a, E>(
user_id: DBUserId,
preferences: &PartialUserPreferences,
exec: E,
) -> Result<(), sqlx::Error>
where
E: Executor<'a, Database = sqlx::Postgres>,
{
sqlx::query!(
r#"
INSERT INTO user_preferences (user_id, preferences)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE
SET preferences = EXCLUDED.preferences
"#,
user_id.0,
Json(preferences) as Json<&PartialUserPreferences>,
)
.execute(exec)
.await?;
Ok(())
}
}
+3 -2
View File
@@ -317,8 +317,9 @@ vars! {
NEVERBOUNCE_API_KEY: String = "";
NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL;
EMAIL_DOMAIN_BLACKLIST: StringCsv = StringCsv(vec![]);
EMAIL_DOMAIN_WHITELIST: StringCsv = StringCsv(vec![]);
USERCHECK_API_KEY: String = "";
USERCHECK_GATE_ID: String = "";
USERCHECK_BASE_URL: String = crate::util::usercheck::DEFAULT_API_URL;
CLICKHOUSE_REPLICATED: bool = false;
CLICKHOUSE_URL: String = "http://localhost:8123";
+5 -1
View File
@@ -42,6 +42,7 @@ pub struct LegacyResultSearchProject {
pub client_side: String,
pub server_side: String,
pub environment: Vec<String>,
pub disclosure_types: Vec<String>,
pub gallery: Vec<String>,
pub featured_gallery: Option<String>,
pub color: Option<u32>,
@@ -151,8 +152,11 @@ impl LegacyResultSearchProject {
client_side,
server_side,
environment: environments,
disclosure_types: result_search_project.disclosure_types,
versions,
latest_version: result_search_project.version_id,
latest_version: result_search_project
.version_id
.unwrap_or_default(),
categories,
project_id: result_search_project.project_id,
+12 -8
View File
@@ -111,8 +111,10 @@ impl DisclosureLockStatus {
pub struct ProjectDisclosureData {
#[serde(flatten)]
pub disclosure: ProjectDisclosure,
pub set_by_moderator: bool,
pub lock_status: DisclosureLockStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub set_by_moderator: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lock_status: Option<DisclosureLockStatus>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<UserId>,
@@ -124,16 +126,18 @@ impl ProjectDisclosureData {
pub fn from_db(
value: DBProjectDisclosure,
viewer_is_moderator: bool,
viewer_is_member: bool,
) -> Self {
let updated_by = (!value.set_by_moderator || viewer_is_moderator)
.then_some(value.updated_by.into());
Self {
disclosure: value.disclosure,
set_by_moderator: value.set_by_moderator,
lock_status: value.lock_status,
set_by_moderator: (viewer_is_member || viewer_is_moderator)
.then_some(value.set_by_moderator),
lock_status: (viewer_is_member || viewer_is_moderator)
.then_some(value.lock_status),
updated_at: value.updated_at,
updated_by,
updated_by: ((!value.set_by_moderator && viewer_is_member)
|| viewer_is_moderator)
.then_some(value.updated_by.into()),
deleted_at: value.deleted_at,
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod organizations;
pub mod pack;
pub mod pats;
pub mod payouts;
pub mod preferences;
pub mod projects;
pub mod reports;
pub mod sessions;
+162
View File
@@ -0,0 +1,162 @@
use component_derive::Component;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, Deserialize, ToSchema, Default, Component)]
pub struct UserPreferences {
#[component(nested)]
pub appearance: AppearancePreferences,
#[component(nested)]
pub behavior: BehaviorPreferences,
#[component(nested)]
pub localization: LocalizationPreferences,
#[component(nested)]
pub layouts: LayoutPreferences,
#[component(nested)]
pub sidebars: SidebarPreferences,
#[component(nested)]
pub social: SocialPreferences,
}
impl UserPreferences {
pub fn resolve(overrides: Option<PartialUserPreferences>) -> Self {
let mut preferences = Self::default();
if let Some(overrides) = overrides {
overrides.apply_to(&mut preferences);
}
preferences
}
}
#[derive(
Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component,
)]
pub struct AppearancePreferences {
pub auto: bool,
pub theme: Theme,
}
#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)]
pub struct BehaviorPreferences {
pub minimize_app: bool,
pub hide_right_sidebar: bool,
pub show_jump_in: bool,
pub compact_instance_cards: bool,
pub show_play_time: bool,
pub hide_nametag: bool,
pub warn_on_unknown_modpacks: bool,
pub skip_non_essential_warnings: bool,
}
impl Default for BehaviorPreferences {
fn default() -> Self {
Self {
minimize_app: false,
hide_right_sidebar: false,
show_jump_in: true,
compact_instance_cards: false,
show_play_time: true,
hide_nametag: false,
warn_on_unknown_modpacks: true,
skip_non_essential_warnings: false,
}
}
}
#[derive(
Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq,
)]
#[serde(rename_all = "snake_case")]
pub enum Theme {
Light,
#[default]
Dark,
Oled,
Retro,
}
#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)]
pub struct LocalizationPreferences {
pub locale: String,
}
impl Default for LocalizationPreferences {
fn default() -> Self {
Self {
locale: "en-US".to_owned(),
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)]
pub struct LayoutPreferences {
pub mods: LayoutOption,
pub plugins: LayoutOption,
pub datapacks: LayoutOption,
pub shaders: LayoutOption,
pub resourcepacks: LayoutOption,
pub modpacks: LayoutOption,
pub servers: LayoutOption,
pub users: LayoutOption,
}
impl Default for LayoutPreferences {
fn default() -> Self {
Self {
mods: LayoutOption::Rows,
plugins: LayoutOption::Rows,
datapacks: LayoutOption::Rows,
shaders: LayoutOption::Grid,
resourcepacks: LayoutOption::Grid,
modpacks: LayoutOption::Rows,
servers: LayoutOption::Rows,
users: LayoutOption::Rows,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum LayoutOption {
Grid,
Rows,
}
#[derive(
Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component,
)]
pub struct SidebarPreferences {
pub right_aligned_search: bool,
pub left_aligned_content: bool,
}
#[derive(
Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component,
)]
pub struct SocialPreferences {
pub friend_privacy: FriendPrivacy,
pub shared_instances_privacy: InvitePrivacy,
pub hosting_access_privacy: InvitePrivacy,
}
#[derive(
Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq,
)]
#[serde(rename_all = "snake_case")]
pub enum FriendPrivacy {
None,
Mutual,
#[default]
Everyone,
}
#[derive(
Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq,
)]
#[serde(rename_all = "snake_case")]
pub enum InvitePrivacy {
None,
Friends,
#[default]
Everyone,
}
+2
View File
@@ -724,6 +724,7 @@ async fn get_tremendous_payout_methods(
for product in response.products {
const BLACKLISTED_IDS: &[&str] = &[
// typos:off
// physical visa
"A2J05SWPI2QG",
// crypto
@@ -756,6 +757,7 @@ async fn get_tremendous_payout_methods(
"NL4JQ2G7UPRZ",
"OEFTMSBA5ELH",
"A3CQK6UHNV27",
// typos:on
];
const SUPPORTED_METHODS: &[&str] = &[
"merchant_cards",
@@ -36,7 +36,7 @@ pub struct AnalyticsEventUpsert {
pub ends: DateTime<Utc>,
}
/// Create an analytics event.
/// Create an analytics event.
#[utoipa::path(
context_path = "/analytics-event",
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
@@ -96,7 +96,7 @@ pub async fn analytics_event_create(
Ok(web::Json(event.into()))
}
/// Update an analytics event.
/// Update an analytics event.
#[utoipa::path(
context_path = "/analytics-event",
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
@@ -148,7 +148,7 @@ pub async fn analytics_event_edit(
Ok(web::Json(event.into()))
}
/// Delete an analytics event.
/// Delete an analytics event.
#[utoipa::path(
context_path = "/analytics-event",
tag = "analytics events", responses((status = NO_CONTENT))
+87 -102
View File
@@ -26,6 +26,9 @@ 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::usercheck::{
DecisionAction, check_email_gate, gate_block_error,
};
use crate::util::validate::validation_errors_to_string;
use actix_http::header::LOCATION;
use actix_web::http::StatusCode;
@@ -51,7 +54,7 @@ use sha2::Sha256;
use std::collections::HashMap;
use std::str::FromStr;
use thiserror::Error;
use tracing::{error, info};
use tracing::{error, info, warn};
use url::Url;
use uuid::Uuid;
use validator::Validate;
@@ -63,10 +66,6 @@ use webauthn_rs::prelude::{
use xredis::RedisPool;
use zxcvbn::Score;
/// Sourced from <https://github.com/disposable-email-domains/disposable-email-domains>.
const DISPOSABLE_EMAIL_BLOCKLIST: &str =
include_str!("../../../assets/disposable_email_blocklist.txt");
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(
web::scope("/auth")
@@ -248,7 +247,7 @@ impl TempUser {
totp_secret: None,
username,
email: self.email.clone(),
email_verified: self.email.is_some(),
email_verified: false,
avatar_url,
raw_avatar_url,
bio: self.bio,
@@ -1460,6 +1459,40 @@ fn validate_account_consent(account_consent: bool) -> Result<(), ApiError> {
Ok(())
}
async fn send_verify_email(
email_queue: &EmailQueue,
txn: &mut PgTransaction<'_>,
redis: &RedisPool,
user_id: DBUserId,
email_address: String,
) -> Result<(), ApiError> {
let mailbox: Mailbox = email_address
.parse()
.wrap_request_err("invalid email address!".to_string())?;
let flow = DBFlow::ConfirmEmail {
user_id,
confirm_email: email_address,
}
.insert(Duration::hours(24), redis)
.await
.wrap_internal_err("storing email-verification flow in Redis")?;
email_queue
.send_one(
txn,
NotificationBody::VerifyEmail { flow },
user_id,
mailbox,
)
.await
.wrap_api_err("sending account email")?
.as_user_error()
.wrap_api_err("validating email delivery status")?;
Ok(())
}
/// Create account with OAuth.
#[utoipa::path(
context_path = "/auth",
@@ -1477,6 +1510,7 @@ pub async fn create_oauth_account(
db: Data<PgPool>,
file_host: Data<dyn FileHost>,
redis: Data<RedisPool>,
email_queue: Data<EmailQueue>,
web::Json(new_account): web::Json<NewOAuthAccount>,
) -> Result<HttpResponse, ApiError> {
new_account
@@ -1511,8 +1545,9 @@ pub async fn create_oauth_account(
};
if let Some(email) = &user.email {
ensure_email_domain_is_allowed(email)
.wrap_api_err("validating email domain is allowed")?;
ensure_email_passes_gate(&redis, email)
.await
.wrap_api_err("validating email passes the signup gate")?;
}
let mut txn = db
@@ -1520,6 +1555,8 @@ pub async fn create_oauth_account(
.await
.wrap_internal_err("failed to begin transaction")?;
let account_email = user.email.clone();
let user_id = user
.create_account(
provider,
@@ -1533,6 +1570,23 @@ pub async fn create_oauth_account(
.await
.wrap_auth_err("inserting user ID into database")?;
if let Some(email_address) = account_email {
// The address comes from the OAuth provider, so the user cannot correct
// it here. A failed send shouldn't block signup: they can resend the
// verification email once they're signed in.
if let Err(error) = send_verify_email(
&email_queue,
&mut txn,
&redis,
user_id,
email_address,
)
.await
{
warn!(%error, "failed to send OAuth signup verification email");
}
}
let session = issue_session(req, user_id, &mut txn, &redis, None)
.await
.wrap_auth_err("authenticating API request")?;
@@ -1848,82 +1902,29 @@ impl From<NewAccount> for AccountRegisterFlow {
}
}
#[derive(PartialEq, Eq)]
enum EmailDomainStatus {
Whitelisted,
Neutral,
}
/// Environment list entries are matched literally, unless they begin with `*.`,
/// in which case they match any subdomain of the remaining suffix.
fn matches_domain_entry(domain: &str, entry: &str) -> bool {
let entry = entry.trim().to_ascii_lowercase();
match entry.strip_prefix("*.") {
Some(suffix) => domain
.strip_suffix(suffix)
.is_some_and(|subdomain| subdomain.ends_with('.')),
None => entry == domain,
}
}
fn is_whitelisted_domain(domain: &str) -> bool {
let domain = domain.to_ascii_lowercase();
ENV.EMAIL_DOMAIN_WHITELIST
.iter()
.any(|entry| matches_domain_entry(&domain, entry))
}
/// The bundled disposable domain list is checked first, then the environment
/// blacklist.
fn is_blacklisted_domain(domain: &str) -> bool {
let domain = domain.to_ascii_lowercase();
if DISPOSABLE_EMAIL_BLOCKLIST.lines().any(|entry| {
// The upstream list expects listed domains to match subdomains too.
domain == entry
|| domain
.strip_suffix(entry)
.is_some_and(|subdomain| subdomain.ends_with('.'))
}) {
return true;
}
ENV.EMAIL_DOMAIN_BLACKLIST
.iter()
.any(|entry| matches_domain_entry(&domain, entry))
}
fn ensure_email_domain_is_allowed(
/// Runs the UserCheck gate, which covers both password and OAuth signups.
async fn ensure_email_passes_gate(
redis: &RedisPool,
email: &str,
) -> Result<EmailDomainStatus, ApiError> {
let Some((_, domain)) = email.rsplit_once('@') else {
return Err(ApiError::Request(email_check_error_generic()));
};
) -> Result<(), ApiError> {
let action = check_email_gate(redis, email)
.await
.wrap_request_err("checking email address")?;
if is_whitelisted_domain(domain) {
info!(email.domain = domain, "whitelisted email domain, allowing");
return Ok(EmailDomainStatus::Whitelisted);
if action == DecisionAction::Block {
return Err(ApiError::Request(gate_block_error()));
}
if is_blacklisted_domain(domain) {
info!(email.domain = domain, "blacklisted email domain, denying");
return Err(ApiError::Request(email_check_error_generic()));
}
Ok(EmailDomainStatus::Neutral)
Ok(())
}
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
let status = ensure_email_domain_is_allowed(email)
.wrap_api_err("validating email domain is allowed")?;
async fn ensure_email_is_usable(
redis: &RedisPool,
email: &str,
) -> Result<(), ApiError> {
ensure_email_passes_gate(redis, email).await?;
if status == EmailDomainStatus::Whitelisted {
return Ok(());
}
let result = check_email(email)
let result = check_email(redis, email)
.await
.wrap_request_err("checking email address")?;
@@ -2076,30 +2077,14 @@ impl ReadyAccountRegisterFlow {
.wrap_auth_err("authenticating API request")?;
let res = crate::models::sessions::Session::from(session, true, None);
let mailbox: Mailbox = register_flow
.email
.parse()
.wrap_request_err("invalid email address!".to_string())?;
let flow = DBFlow::ConfirmEmail {
send_verify_email(
email_queue,
transaction,
redis,
user_id,
confirm_email: register_flow.email.clone(),
}
.insert(Duration::hours(24), redis)
.await
.wrap_internal_err("storing email-verification flow in Redis")?;
email_queue
.send_one(
transaction,
NotificationBody::VerifyEmail { flow },
user_id,
mailbox,
)
.await
.wrap_api_err("sending account email")?
.as_user_error()
.wrap_api_err("validating email delivery status")?;
register_flow.email,
)
.await?;
Ok(res)
}
@@ -2166,7 +2151,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")?;
@@ -3071,7 +3056,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")?;
+3
View File
@@ -16,6 +16,7 @@ pub mod moderation;
pub mod mural;
pub mod pats;
pub mod payout_runs;
pub mod privacy;
pub mod search;
pub mod server_ping;
pub mod session;
@@ -33,6 +34,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.wrap(default_cors())
.configure(admin::config)
.configure(blocked_users::config)
.configure(privacy::config)
.configure(session::config)
.configure(flows::config)
.configure(pats::config)
@@ -70,6 +72,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
paths(
admin::count_download,
blocked_users::block_status,
privacy::invite_privacy_status,
admin::force_reindex,
admin::force_reindex_project,
session::list,
@@ -0,0 +1,89 @@
use crate::database::PgPool;
use crate::database::models::blocked_user_item::DBBlockedUser;
use crate::database::models::friend_item::DBFriend;
use crate::database::models::user_item::DBUser;
use crate::database::models::user_preferences_item::DBUserPreferences;
use crate::models::v3::preferences::{InvitePrivacy, UserPreferences};
use crate::routes::ApiError;
use crate::util::error::Context as _;
use crate::util::guards::admin_key_guard;
use actix_web::{get, web};
use serde::Serialize;
use xredis::RedisPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(invite_privacy_status);
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct InvitePrivacyStatus {
pub allowed: bool,
}
#[derive(strum::EnumString)]
#[strum(serialize_all = "snake_case")]
enum InviteType {
SharedInstances,
HostingAccess,
}
/// Check whether `user_id` may send `target_id` an invite of the given type.
#[utoipa::path(tag = "privacy", responses((status = OK, body = InvitePrivacyStatus)))]
#[get(
"/privacy/{invite_type}/{user_id}/{target_id}",
guard = "admin_key_guard"
)]
pub async fn invite_privacy_status(
info: web::Path<(String, String, String)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<web::Json<InvitePrivacyStatus>, ApiError> {
let (invite_type, user_id, target_id) = info.into_inner();
let invite_type = invite_type
.parse::<InviteType>()
.wrap_request_err("invalid invite_type")?;
let (user, target) = futures::try_join!(
DBUser::get(&user_id, &**pool, &redis),
DBUser::get(&target_id, &**pool, &redis),
)
.wrap_internal_err("resolving user ids")?;
let user_id = user.wrap_not_found_err("user not found")?.id;
let target_id = target.wrap_not_found_err("target not found")?.id;
let blocked = DBBlockedUser::is_blocked(target_id, user_id, &**pool)
.await
.wrap_internal_err("checking whether user is blocked")?;
let allowed = if blocked {
false
} else {
let overrides = DBUserPreferences::get(target_id, &**pool)
.await
.wrap_internal_err("fetching user preferences")?;
let preferences = UserPreferences::resolve(overrides);
let privacy = match invite_type {
InviteType::SharedInstances => {
preferences.social.shared_instances_privacy
}
InviteType::HostingAccess => {
preferences.social.hosting_access_privacy
}
};
match privacy {
InvitePrivacy::Everyone => true,
InvitePrivacy::None => false,
InvitePrivacy::Friends => {
DBFriend::get_friend(user_id, target_id, &**pool)
.await
.wrap_internal_err("checking friendship status")?
.is_some_and(|friend| friend.accepted)
}
}
};
Ok(web::Json(InvitePrivacyStatus { allowed }))
}
+5 -2
View File
@@ -277,8 +277,11 @@ fn find_file<'a>(
if file_name.eq_ignore_ascii_case(&formatted_name) {
return filtered_files
.find(|x| x.primary)
.or_else(|| filtered_files.next_back());
.try_fold(
None,
|_, x| if x.primary { Err(x) } else { Ok(Some(x)) },
)
.unwrap_or_else(Some);
} else if file_name.len() > formatted_name.len()
&& file_name.as_bytes()[..formatted_name.len()]
.eq_ignore_ascii_case(formatted_name.as_bytes())
+5 -2
View File
@@ -193,7 +193,10 @@ pub async fn random_projects_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let count = v3::projects::RandomProjects { count: count.count };
let count = v3::projects::RandomProjects {
count: count.count,
project_type: None,
};
let response = v3::projects::random_projects_get(
web::Query(count),
@@ -376,7 +379,7 @@ struct DependencyInfo {
get,
operation_id = "getDependencies",
params(
("id" = String, Path, description = "The ID or slug of the project")
("project_id" = String, Path, description = "The ID or slug of the project")
),
responses(
(status = 200, description = "Expected response to a valid request", body = DependencyInfo),
@@ -661,6 +661,8 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
(r"^DawnLauncher/", P::Named("Dawn")),
(r"^Complementary-Installer", P::Named("Complementary Installer")),
(r"^noriskclient-launcher-v3/", P::Named("NoRisk Client")),
(r"^Resourcify/", P::Named("Resourcify")),
(r"^OneClient", P::Named("OneClient")),
(
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
P::Website,
+8 -3
View File
@@ -73,14 +73,15 @@ pub async fn get_project_disclosures(
let viewer_is_moderator =
user_option.as_ref().is_some_and(|user| user.role.is_mod());
let include_deleted = viewer_is_moderator
// Moderators can see regardless of membership, short circuit to avoid extra db call
let viewer_is_member = viewer_is_moderator
|| is_team_member_project(&project.inner, &user_option, &pool)
.await
.wrap_internal_err("failed to check project team membership")?;
let disclosures = db_models::DBProjectDisclosure::get_many_for_project(
project.inner.id,
include_deleted,
viewer_is_moderator || viewer_is_member,
&***ro_pool,
)
.await
@@ -90,7 +91,11 @@ pub async fn get_project_disclosures(
disclosures: disclosures
.into_iter()
.map(|disclosure| {
ProjectDisclosureData::from_db(disclosure, viewer_is_moderator)
ProjectDisclosureData::from_db(
disclosure,
viewer_is_moderator,
viewer_is_member,
)
})
.collect(),
}))
+2
View File
@@ -211,6 +211,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
users::user_delete_route,
users::user_follows_route,
users::user_notifications_route,
users::get_user_preferences,
users::edit_user_preferences,
version_creation::version_create_route,
version_creation::upload_file_to_version_route,
version_file::get_version_from_hash_route,
+3 -3
View File
@@ -86,7 +86,7 @@ pub async fn notifications_get(
Ok(HttpResponse::Ok().json(notifications))
}
#[utoipa::path(tag = "notifications", responses((status = OK)))]
#[utoipa::path(tag = "notifications", params(("id" = NotificationId, Path, description = "Notification id",)), responses((status = OK)))]
#[get("/notification/{id}")]
pub async fn notification_get_route(
req: HttpRequest,
@@ -137,7 +137,7 @@ pub async fn notification_get(
}
}
#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))]
#[utoipa::path(tag = "notifications", params(("id" = NotificationId, Path, description = "Notification id",)), responses((status = NO_CONTENT)))]
#[patch("/notification/{id}")]
pub async fn notification_read_route(
req: HttpRequest,
@@ -208,7 +208,7 @@ pub async fn notification_read(
}
}
#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))]
#[utoipa::path(tag = "notifications", params(("id" = NotificationId, Path, description = "Notification id",)), responses((status = NO_CONTENT)))]
#[delete("/notification/{id}")]
pub async fn notification_delete_route(
req: HttpRequest,
+100 -45
View File
@@ -44,6 +44,7 @@ use chrono::Utc;
use eyre::eyre;
use futures::TryStreamExt;
use itertools::Itertools;
use rand::seq::SliceRandom;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -104,11 +105,15 @@ pub async fn clear_project_cache_and_queue_search(
pub struct RandomProjects {
#[validate(range(min = 1, max = 100))]
pub count: u32,
pub project_type: Option<String>,
}
#[utoipa::path(
tag = "projects",
params(("count" = u32, Query)),
params(
("count" = u32, Query),
("project_type" = Option<String>, Query),
),
responses((status = OK))
)]
#[get("/projects_random")]
@@ -120,37 +125,84 @@ pub async fn random_projects_get_route(
random_projects_get(count, pool, redis).await
}
// Filtered candidates are sparser and unevenly spaced, so the nearest-point pick
// tends to repeat; oversample a neighborhood and shuffle it down to counter that.
const RANDOM_PROJECT_TYPE_OVERSAMPLE_FACTOR: u32 = 20;
pub async fn random_projects_get(
web::Query(count): web::Query<RandomProjects>,
web::Query(params): web::Query<RandomProjects>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
count
params
.validate()
.map_err(|err| eyre::eyre!(err))
.wrap_request_err("validating request")?;
let project_ids = sqlx::query!(
// IDs are randomly generated (see the `generate_ids` macro), so fetching a
// number of mods nearest to a random point in the ID space is equivalent to
// random sampling
"WITH random_id_point AS (
SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point
let statuses = crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
.map(|x| x.to_string())
.collect::<Vec<String>>();
let mut project_ids = if let Some(project_type) = &params.project_type {
let fetch_limit = params.count * RANDOM_PROJECT_TYPE_OVERSAMPLE_FACTOR;
sqlx::query!(
// IDs are randomly generated (see the `generate_ids` macro), so fetching a
// number of mods nearest to a random point in the ID space is equivalent to
// random sampling
"WITH random_id_point AS (
SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point
)
SELECT id FROM mods
WHERE status = ANY($1)
AND EXISTS (
SELECT 1 FROM versions v
INNER JOIN loaders_versions lv ON v.id = lv.version_id
INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id
INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id
WHERE v.mod_id = mods.id AND pt.name = $3
-- prevents decorrelation, so this stops at the first match instead
-- of scanning all versions before the outer sort/limit applies
OFFSET 0
)
ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)
LIMIT $2",
&statuses,
fetch_limit as i32,
project_type,
)
SELECT id FROM mods
WHERE status = ANY($1)
ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)
LIMIT $2",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
.map(|x| x.to_string())
.collect::<Vec<String>>(),
count.count as i32,
)
.fetch(&**pool)
.map_ok(|m| db_ids::DBProjectId(m.id))
.try_collect::<Vec<_>>()
.await.wrap_internal_err("querying random project IDs")?;
.fetch(&**pool)
.map_ok(|m| db_ids::DBProjectId(m.id))
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("querying random project IDs")?
} else {
sqlx::query!(
// IDs are randomly generated (see the `generate_ids` macro), so fetching a
// number of mods nearest to a random point in the ID space is equivalent to
// random sampling
"WITH random_id_point AS (
SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point
)
SELECT id FROM mods
WHERE status = ANY($1)
ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)
LIMIT $2",
&statuses,
params.count as i32,
)
.fetch(&**pool)
.map_ok(|m| db_ids::DBProjectId(m.id))
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("querying random project IDs")?
};
if params.project_type.is_some() {
project_ids.shuffle(&mut rand::thread_rng());
project_ids.truncate(params.count as usize);
}
let projects_data =
db_models::DBProject::get_many_ids(&project_ids, &**pool, &redis)
@@ -1207,6 +1259,14 @@ pub async fn project_edit_internal(
let mut reindex_versions = new_project.categories.is_some()
|| new_project.additional_categories.is_some();
let became_searchable = !project_item.inner.status.is_searchable()
&& new_project
.status
.is_some_and(|status| status.is_searchable());
let became_unsearchable = project_item.inner.status.is_searchable()
&& new_project
.status
.is_some_and(|status| !status.is_searchable());
reindex_versions |= update(
&mut transaction,
@@ -1284,7 +1344,7 @@ pub async fn project_edit_internal(
.await
.wrap_internal_err("committing database transaction")?;
if reindex_versions {
if became_unsearchable {
db_models::DBProject::clear_cache(
project_item.inner.id,
project_item.inner.slug,
@@ -1295,10 +1355,20 @@ pub async fn project_edit_internal(
.wrap_internal_err("clearing cached data from Redis")?;
search_state
.queue
.push_version_changes(
project_item.inner.id.into(),
project_item.versions.iter().copied().map(VersionId::from),
)
.push_project_removal(project_item.inner.id.into())
.await;
} else if reindex_versions || became_searchable {
db_models::DBProject::clear_cache(
project_item.inner.id,
project_item.inner.slug,
None,
&redis,
)
.await
.wrap_internal_err("clearing cached data from Redis")?;
search_state
.queue
.push_project_with_all_versions_change(project_item.inner.id.into())
.await;
} else {
clear_project_cache_and_queue_search(
@@ -1312,17 +1382,6 @@ pub async fn project_edit_internal(
.wrap_api_err("executing `clear_project_cache_and_queue_search`")?;
}
// Remove no longer searchable projects from search index
if let (true, Some(false)) = (
project_item.inner.status.is_searchable(),
new_project.status.map(|status| status.is_searchable()),
) {
search_state
.queue
.push_project_removal(project_item.inner.id.into())
.await;
}
Ok(HttpResponse::NoContent().body(""))
}
@@ -1890,7 +1949,6 @@ pub async fn projects_edit(
changed_projects.push((
project.inner.id,
project.inner.slug,
project.versions,
reindex_versions,
));
}
@@ -1900,17 +1958,14 @@ pub async fn projects_edit(
.await
.wrap_internal_err("committing database transaction")?;
for (project_id, slug, versions, reindex_versions) in changed_projects {
for (project_id, slug, reindex_versions) in changed_projects {
if reindex_versions {
db_models::DBProject::clear_cache(project_id, slug, None, &redis)
.await
.wrap_internal_err("clearing cached data from Redis")?;
search_state
.queue
.push_version_changes(
project_id.into(),
versions.into_iter().map(VersionId::from),
)
.push_project_with_all_versions_change(project_id.into())
.await;
} else {
clear_project_cache_and_queue_search(
+108
View File
@@ -29,9 +29,13 @@ use crate::{
};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
use ariadne::ids::UserId;
use eyre::eyre;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::database::models::user_preferences_item::DBUserPreferences;
use crate::models::v3::preferences::{PartialUserPreferences, UserPreferences};
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(user_auth_get_route)
.service(users_get_route)
@@ -49,6 +53,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.service(user_delete_route)
.service(user_follows_route)
.service(user_notifications_route)
.service(get_user_preferences)
.service(edit_user_preferences)
.service(get_user_clients);
}
@@ -367,6 +373,108 @@ pub async fn user_auth_get(
Ok(HttpResponse::Ok().json(user))
}
#[utoipa::path(tag = "users", responses((status = OK, body = UserPreferences)))]
#[get("/user/{id}/preferences")]
pub async fn get_user_preferences(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<UserPreferences>, ApiError> {
let (_, requester) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_READ,
)
.await
.wrap_auth_err("authenticating API request")?;
let target = DBUser::get(&info.into_inner().0, &**pool, &redis)
.await
.wrap_internal_err("fetching user from database")?
.wrap_not_found_err("resource not found")?;
let can_access =
requester.id == target.id.into() || requester.role.is_mod();
if !can_access {
return Err(ApiError::Auth(eyre!(
"you do not have permission to access this user's preferences"
)));
}
let preference_overrides = DBUserPreferences::get(target.id, &**pool)
.await
.wrap_internal_err("failed to fetch user preferences")?;
Ok(web::Json(UserPreferences::resolve(preference_overrides)))
}
#[utoipa::path(
tag = "users",
request_body = PartialUserPreferences,
responses((status = OK, body = UserPreferences))
)]
#[patch("/user/{id}/preferences")]
pub async fn edit_user_preferences(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
body: web::Json<PartialUserPreferences>,
) -> Result<web::Json<UserPreferences>, ApiError> {
let (_, requester) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await
.wrap_auth_err("authenticating API request")?;
let target = DBUser::get(&info.into_inner().0, &**pool, &redis)
.await
.wrap_internal_err("fetching user from database")?
.wrap_not_found_err("resource not found")?;
let can_access =
requester.id == target.id.into() || requester.role.is_mod();
if !can_access {
return Err(ApiError::Auth(eyre!(
"you do not have permission to access this user's preferences"
)));
}
let mut txn = pool
.begin()
.await
.wrap_internal_err("starting database transaction")?;
let stored = DBUserPreferences::get_for_update(target.id, &mut txn)
.await
.wrap_internal_err("failed to fetch user preferences")?;
let mut preferences = UserPreferences::resolve(stored);
body.into_inner().apply_to(&mut preferences);
let overrides = preferences.into_diff_from(&UserPreferences::default());
DBUserPreferences::upsert(target.id, &overrides, &mut txn)
.await
.wrap_internal_err("failed to update user preferences")?;
txn.commit()
.await
.wrap_internal_err("committing database transaction")?;
let preferences = UserPreferences::resolve(Some(overrides));
Ok(web::Json(preferences))
}
#[derive(Serialize, Deserialize)]
pub struct UserIds {
pub ids: String,
@@ -910,6 +910,7 @@ impl Elasticsearch {
let mut document = hit["_source"].clone();
let object = document.as_object_mut()?;
object.remove("document_type");
object.remove("_search_tokens");
if filter
.as_ref()
.is_some_and(|filter| filter.has_version_filter)
+53 -3
View File
@@ -17,7 +17,7 @@ use crate::{
};
pub const SEARCH_PROJECT_INDEX_QUEUE_TOPIC: &str =
"public.labrinth.search-project-index-queue.v1";
"public.labrinth.search-project-index-queue.v2";
const QUEUE_FLUSH_INTERVAL: Duration = Duration::from_secs(10);
#[derive(Clone)]
@@ -40,6 +40,16 @@ impl IncrementalSearchQueue {
self.operations.lock().await.push_project_change(project_id);
}
pub async fn push_project_with_all_versions_change(
&self,
project_id: ProjectId,
) {
self.operations
.lock()
.await
.push_project_with_all_versions_change(project_id);
}
pub async fn push_version_changes(
&self,
project_id: ProjectId,
@@ -116,6 +126,7 @@ impl IncrementalSearchQueue {
#[derive(Default)]
struct PendingSearchIndexOperations {
changed_project_ids: HashSet<ProjectId>,
changed_project_ids_with_all_versions: HashSet<ProjectId>,
changed_project_versions: HashMap<ProjectId, HashSet<VersionId>>,
removed_project_ids: HashSet<ProjectId>,
}
@@ -123,25 +134,47 @@ struct PendingSearchIndexOperations {
impl PendingSearchIndexOperations {
fn is_empty(&self) -> bool {
self.changed_project_ids.is_empty()
&& self.changed_project_ids_with_all_versions.is_empty()
&& self.changed_project_versions.is_empty()
&& self.removed_project_ids.is_empty()
}
fn push_project_change(&mut self, project_id: ProjectId) {
if !self.removed_project_ids.contains(&project_id) {
if !self.removed_project_ids.contains(&project_id)
&& !self
.changed_project_ids_with_all_versions
.contains(&project_id)
&& !self.changed_project_versions.contains_key(&project_id)
{
self.changed_project_ids.insert(project_id);
}
}
fn push_project_with_all_versions_change(&mut self, project_id: ProjectId) {
if self.removed_project_ids.contains(&project_id) {
return;
}
self.changed_project_ids.remove(&project_id);
self.changed_project_versions.remove(&project_id);
self.changed_project_ids_with_all_versions
.insert(project_id);
}
fn push_version_change(
&mut self,
project_id: ProjectId,
version_ids: impl IntoIterator<Item = VersionId>,
) {
if self.removed_project_ids.contains(&project_id) {
if self.removed_project_ids.contains(&project_id)
|| self
.changed_project_ids_with_all_versions
.contains(&project_id)
{
return;
}
self.changed_project_ids.remove(&project_id);
let version_ids = version_ids.into_iter().collect::<HashSet<_>>();
if !version_ids.is_empty() {
self.changed_project_versions
@@ -153,6 +186,8 @@ impl PendingSearchIndexOperations {
fn push_project_removal(&mut self, project_id: ProjectId) {
self.changed_project_ids.remove(&project_id);
self.changed_project_ids_with_all_versions
.remove(&project_id);
self.changed_project_versions.remove(&project_id);
self.removed_project_ids.insert(project_id);
}
@@ -162,6 +197,9 @@ impl PendingSearchIndexOperations {
SearchProjectIndexQueueEventData::Change { project_id } => {
self.push_project_change(project_id)
}
SearchProjectIndexQueueEventData::ChangeWithAllVersions {
project_id,
} => self.push_project_with_all_versions_change(project_id),
SearchProjectIndexQueueEventData::VersionChange {
project_id,
version_ids,
@@ -175,6 +213,7 @@ impl PendingSearchIndexOperations {
fn into_events(self) -> Vec<SearchProjectIndexQueueEventData> {
let mut events = Vec::with_capacity(
self.changed_project_ids.len()
+ self.changed_project_ids_with_all_versions.len()
+ self.changed_project_versions.len()
+ self.removed_project_ids.len(),
);
@@ -185,6 +224,15 @@ impl PendingSearchIndexOperations {
events.extend(self.changed_project_ids.into_iter().map(|project_id| {
SearchProjectIndexQueueEventData::Change { project_id }
}));
events.extend(
self.changed_project_ids_with_all_versions.into_iter().map(
|project_id| {
SearchProjectIndexQueueEventData::ChangeWithAllVersions {
project_id,
}
},
),
);
events.extend(self.changed_project_versions.into_iter().map(
|(project_id, version_ids)| {
SearchProjectIndexQueueEventData::VersionChange {
@@ -202,6 +250,8 @@ impl PendingSearchIndexOperations {
pub enum SearchProjectIndexQueueEventData {
#[serde(rename = "project_change")]
Change { project_id: ProjectId },
#[serde(rename = "project_change_with_all_versions")]
ChangeWithAllVersions { project_id: ProjectId },
#[serde(rename = "project_version_change")]
VersionChange {
project_id: ProjectId,
@@ -21,7 +21,10 @@ use crate::{
SearchBackend, SearchDocumentBatch, SearchIndexUpdate,
UploadSearchProject,
incremental::SEARCH_PROJECT_INDEX_QUEUE_TOPIC,
indexing::{build_project_documents, build_version_change_documents},
indexing::{
build_project_and_all_version_documents, build_project_documents,
build_version_change_documents,
},
},
util::kafka::{
INCREMENTAL_INDEX_SEARCH_TASK, KAFKA_OPERATION_INTERVAL,
@@ -132,6 +135,7 @@ async fn consume_batch(
let start = Instant::now();
let mut project_ids_to_change = HashSet::new();
let mut project_ids_with_all_versions_to_change = HashSet::new();
let mut project_ids_with_version_changes = HashSet::new();
let mut project_ids_to_remove = HashSet::new();
let mut version_ids_to_change = HashSet::new();
@@ -180,6 +184,11 @@ async fn consume_batch(
SearchProjectIndexQueueEventData::Change { project_id } => {
project_ids_to_change.insert(project_id);
}
SearchProjectIndexQueueEventData::ChangeWithAllVersions {
project_id,
} => {
project_ids_with_all_versions_to_change.insert(project_id);
}
SearchProjectIndexQueueEventData::VersionChange {
project_id,
version_ids,
@@ -198,16 +207,26 @@ async fn consume_batch(
project_ids_to_change
.retain(|project_id| !project_ids_to_remove.contains(project_id));
project_ids_with_all_versions_to_change
.retain(|project_id| !project_ids_to_remove.contains(project_id));
project_ids_with_version_changes
.retain(|project_id| !project_ids_to_remove.contains(project_id));
project_ids_with_version_changes.retain(|project_id| {
!project_ids_with_all_versions_to_change.contains(project_id)
});
project_ids_to_change.retain(|project_id| {
!project_ids_with_version_changes.contains(project_id)
&& !project_ids_with_all_versions_to_change.contains(project_id)
});
let project_ids_to_change =
project_ids_to_change.into_iter().collect::<Vec<_>>();
let project_ids_with_version_changes = project_ids_with_version_changes
.into_iter()
.collect::<Vec<_>>();
let project_ids_with_all_versions_to_change =
project_ids_with_all_versions_to_change
.into_iter()
.collect::<Vec<_>>();
let mut project_ids_to_remove =
project_ids_to_remove.into_iter().collect::<Vec<_>>();
let version_ids_to_change =
@@ -215,9 +234,10 @@ async fn consume_batch(
info!(
kafka.message_count = messages_to_commit.len(),
"Read all Kafka messages in {:.2?}, found {} projects to change, {} projects with {} version changes, and {} projects to remove",
"Read all Kafka messages in {:.2?}, found {} projects to change, {} projects with all versions to change, {} projects with {} version changes, and {} projects to remove",
start.elapsed(),
project_ids_to_change.len(),
project_ids_with_all_versions_to_change.len(),
project_ids_with_version_changes.len(),
version_ids_to_change.len(),
project_ids_to_remove.len(),
@@ -225,6 +245,35 @@ async fn consume_batch(
let start = Instant::now();
let mut documents = SearchDocumentBatch::default();
if !project_ids_with_all_versions_to_change.is_empty() {
let operation_start = Instant::now();
let changed_documents = build_project_and_all_version_documents(
ro_pool,
redis_pool,
&project_ids_with_all_versions_to_change,
)
.instrument(info_span!(
"index",
batch_size = project_ids_with_all_versions_to_change.len()
))
.await
.wrap_err_with(|| {
format!(
"failed to build search documents for {} projects and all their versions",
project_ids_with_all_versions_to_change.len()
)
})?;
project_ids_to_remove
.extend(project_ids_with_all_versions_to_change.iter().copied());
documents.projects.extend(changed_documents.projects);
documents.versions.extend(changed_documents.versions);
info!(
project_count = project_ids_with_all_versions_to_change.len(),
"Built changed projects and all their versions in {:.2?}",
operation_start.elapsed()
);
}
if !project_ids_with_version_changes.is_empty() {
let operation_start = Instant::now();
let changed_documents = build_version_change_documents(
@@ -402,6 +451,8 @@ enum SearchProjectIndexQueueEvent {
enum SearchProjectIndexQueueEventData {
#[serde(rename = "project_change")]
Change { project_id: ProjectId },
#[serde(rename = "project_change_with_all_versions")]
ChangeWithAllVersions { project_id: ProjectId },
#[serde(rename = "project_version_change")]
VersionChange {
project_id: ProjectId,
+199 -199
View File
@@ -129,11 +129,22 @@ pub async fn build_project_documents(
project_ids: &[ProjectId],
) -> eyre::Result<Vec<UploadSearchProject>> {
let version_ids = HashSet::new();
Ok(
build_search_document_batch(pool, redis, project_ids, &version_ids)
.await?
.projects,
Ok(build_search_document_batch(
pool,
redis,
project_ids,
Some(&version_ids),
)
.await?
.projects)
}
pub async fn build_project_and_all_version_documents(
pool: &PgPool,
redis: &RedisPool,
project_ids: &[ProjectId],
) -> eyre::Result<SearchDocumentBatch> {
build_search_document_batch(pool, redis, project_ids, None).await
}
pub async fn build_version_change_documents(
@@ -147,14 +158,15 @@ pub async fn build_version_change_documents(
.copied()
.map(DBVersionId::from)
.collect::<HashSet<_>>();
build_search_document_batch(pool, redis, project_ids, &version_ids).await
build_search_document_batch(pool, redis, project_ids, Some(&version_ids))
.await
}
async fn build_search_document_batch(
pool: &PgPool,
redis: &RedisPool,
project_ids: &[ProjectId],
version_ids: &HashSet<DBVersionId>,
version_ids: Option<&HashSet<DBVersionId>>,
) -> eyre::Result<SearchDocumentBatch> {
let searchable_statuses = searchable_statuses();
let project_ids = project_ids
@@ -194,7 +206,7 @@ async fn build_search_document_batch(
.await
.wrap_err("failed to fetch project")?;
build_search_documents(pool, redis, db_projects, Some(version_ids)).await
build_search_documents(pool, redis, db_projects, version_ids).await
}
async fn build_search_documents(
@@ -612,231 +624,219 @@ async fn build_search_documents(
.map(|dependency| dependency.project_id.clone())
.collect::<Vec<_>>();
if let Some(versions) = versions.remove(&project.id) {
let Some(latest_version) = versions.iter().max_by(|a, b| {
a.date_published
.cmp(&b.date_published)
.then_with(|| a.id.0.cmp(&b.id.0))
}) else {
continue;
};
let versions = versions.remove(&project.id).unwrap_or_default();
let latest_version = versions.iter().max_by(|a, b| {
a.date_published
.cmp(&b.date_published)
.then_with(|| a.id.0.cmp(&b.id.0))
});
let project_version_fields = versions
.iter()
.flat_map(|x| x.version_fields.clone())
.collect::<Vec<_>>();
let aggregated_version_fields = VersionField::from_query_json(
project_version_fields,
let project_version_fields = versions
.iter()
.flat_map(|x| x.version_fields.clone())
.collect::<Vec<_>>();
let aggregated_version_fields = VersionField::from_query_json(
project_version_fields,
&loader_field_definitions,
&loader_field_enum_values,
true,
);
let unvectorized_loader_fields = aggregated_version_fields
.iter()
.map(|field| {
(field.field_name.clone(), field.value.serialize_internal())
})
.collect();
let mut loader_fields =
from_duplicate_version_fields(aggregated_version_fields);
let project_loader_fields = loader_fields.clone();
let mut project_loaders = versions
.iter()
.flat_map(|x| x.loaders.clone())
.collect::<Vec<_>>();
project_loaders.sort();
project_loaders.dedup();
let mut project_types = versions
.iter()
.flat_map(|x| x.project_types.clone())
.collect::<Vec<_>>();
project_types.sort();
project_types.dedup();
exp::compat::correct_project_types(
&project.components,
&mut project_types,
);
let project_id = ProjectId::from(project.id).to_string();
version_uploads.extend(versions.iter().filter_map(|version| {
if version_ids
.is_some_and(|version_ids| !version_ids.contains(&version.id))
{
return None;
}
let version_fields = VersionField::from_query_json(
version.version_fields.clone(),
&loader_field_definitions,
&loader_field_enum_values,
true,
false,
);
let unvectorized_loader_fields = aggregated_version_fields
let unvectorized_loader_fields = version_fields
.iter()
.map(|field| {
(field.field_name.clone(), field.value.serialize_internal())
})
.collect();
let mut loader_fields =
from_duplicate_version_fields(aggregated_version_fields);
let project_loader_fields = loader_fields.clone();
let mut project_loaders = versions
.iter()
.flat_map(|x| x.loaders.clone())
.collect::<Vec<_>>();
project_loaders.sort();
project_loaders.dedup();
let mut project_types = versions
.iter()
.flat_map(|x| x.project_types.clone())
.collect::<Vec<_>>();
project_types.sort();
project_types.dedup();
let mut fields = from_duplicate_version_fields(version_fields);
let mut version_project_types = version.project_types.clone();
exp::compat::correct_project_types(
&project.components,
&mut project_types,
&mut version_project_types,
);
let project_id = ProjectId::from(project.id).to_string();
version_uploads.extend(versions.iter().filter_map(|version| {
if version_ids.is_some_and(|version_ids| {
!version_ids.contains(&version.id)
}) {
return None;
}
let version_fields = VersionField::from_query_json(
version.version_fields.clone(),
&loader_field_definitions,
&loader_field_enum_values,
false,
);
let unvectorized_loader_fields = version_fields
.iter()
.map(|field| {
(
field.field_name.clone(),
field.value.serialize_internal(),
)
})
.collect();
let mut fields = from_duplicate_version_fields(version_fields);
let mut version_project_types = version.project_types.clone();
exp::compat::correct_project_types(
&project.components,
&mut version_project_types,
);
// SPECIAL BEHAVIOUR
// Todo: revisit.
// For consistency with v2 searching, we consider the loader field 'mrpack_loaders' to be a category.
// These were previously considered the loader, and in v2, the loader is a category for searching.
// So to avoid breakage or awkward conversions, we just consider those loader_fields to be categories.
// The loaders are kept in the project document's aggregated loader fields as well, so that no information is lost on retrieval.
let mut version_categories = project_categories.clone();
version_categories.extend(version.loaders.iter().cloned());
let mrpack_loaders = fields
.get("mrpack_loaders")
.into_iter()
.flatten()
.filter_map(|value| value.as_str().map(String::from))
.collect::<Vec<_>>();
version_categories.extend(mrpack_loaders);
if fields.contains_key("mrpack_loaders") {
version_categories.retain(|category| category != "mrpack");
}
version_categories.sort();
version_categories.dedup();
let (_, v2_og_project_type) =
LegacyProject::get_project_type(&version_project_types);
let (client_side, server_side) =
v2_reroute::convert_v3_side_types_to_v2_side_types(
&unvectorized_loader_fields,
Some(&v2_og_project_type),
);
if let Ok(client_side) = serde_json::to_value(client_side) {
fields.insert("client_side".to_string(), vec![client_side]);
}
if let Ok(server_side) = serde_json::to_value(server_side) {
fields.insert("server_side".to_string(), vec![server_side]);
}
fields.retain(|field, _| {
matches!(
field.as_str(),
"environment"
| "game_versions"
| "client_side"
| "server_side"
)
});
Some(UploadSearchVersion {
version_id: VersionId::from(version.id).to_string(),
project_id: project_id.clone(),
categories: version_categories,
project_types: version_project_types,
version_published_timestamp: version
.date_published
.timestamp(),
loader_fields: fields,
})
}));
let mut categories = project_categories.clone();
categories.extend(project_loaders.iter().cloned());
let mrpack_loaders = loader_fields
// SPECIAL BEHAVIOUR
// Todo: revisit.
// For consistency with v2 searching, we consider the loader field 'mrpack_loaders' to be a category.
// These were previously considered the loader, and in v2, the loader is a category for searching.
// So to avoid breakage or awkward conversions, we just consider those loader_fields to be categories.
// The loaders are kept in the project document's aggregated loader fields as well, so that no information is lost on retrieval.
let mut version_categories = project_categories.clone();
version_categories.extend(version.loaders.iter().cloned());
let mrpack_loaders = fields
.get("mrpack_loaders")
.into_iter()
.flatten()
.filter_map(|value| value.as_str().map(String::from))
.collect::<Vec<_>>();
categories.extend(mrpack_loaders);
if loader_fields.contains_key("mrpack_loaders") {
categories.retain(|category| category != "mrpack");
version_categories.extend(mrpack_loaders);
if fields.contains_key("mrpack_loaders") {
version_categories.retain(|category| category != "mrpack");
}
categories.sort();
categories.dedup();
version_categories.sort();
version_categories.dedup();
let (_, v2_og_project_type) =
LegacyProject::get_project_type(&project_types);
LegacyProject::get_project_type(&version_project_types);
let (client_side, server_side) =
v2_reroute::convert_v3_side_types_to_v2_side_types(
&unvectorized_loader_fields,
Some(&v2_og_project_type),
);
if let Ok(client_side) = serde_json::to_value(client_side) {
loader_fields
.insert("client_side".to_string(), vec![client_side]);
fields.insert("client_side".to_string(), vec![client_side]);
}
if let Ok(server_side) = serde_json::to_value(server_side) {
loader_fields
.insert("server_side".to_string(), vec![server_side]);
fields.insert("server_side".to_string(), vec![server_side]);
}
let components = project
.components
.clone()
.into_query(ProjectId::from(project.id), &project_query_context)
.wrap_err("failed to populate query components")?;
let indexed_name = normalize_for_search(&project.name);
project_uploads.push(UploadSearchProject {
version_id: crate::models::ids::VersionId::from(
latest_version.id,
fields.retain(|field, _| {
matches!(
field.as_str(),
"environment"
| "game_versions"
| "client_side"
| "server_side"
)
.to_string(),
project_id,
name: project.name,
indexed_name,
summary: project.summary,
categories,
project_categories,
display_categories,
follows: project.follows,
downloads: project.downloads,
log_downloads: (project.downloads.max(1) as f64).ln(),
icon_url: project.icon_url,
author: username.clone(),
author_id: ariadne::ids::UserId::from(user_id).to_string(),
organization: org_name,
organization_id: org_id.map(|id| {
crate::models::ids::OrganizationId::from(id).to_string()
}),
indexed_author: normalize_for_search(&username),
date_created: project.approved,
created_timestamp: project.approved.timestamp(),
date_modified: project.updated,
modified_timestamp: project.updated.timestamp(),
version_published_timestamp: latest_version
.date_published
.timestamp(),
license,
slug: project.slug,
project_types: project_types.clone(),
all_project_types: project_types,
gallery,
featured_gallery,
open_source,
color: project.color.map(|x| x as u32),
dependency_project_ids,
compatible_dependency_project_ids,
required_dependency_project_ids,
optional_dependency_project_ids,
embedded_dependency_project_ids,
incompatible_dependency_project_ids,
dependencies,
disclosure_types,
project_loader_fields,
loader_fields,
loaders: project_loaders,
components,
});
Some(UploadSearchVersion {
version_id: VersionId::from(version.id).to_string(),
project_id: project_id.clone(),
categories: version_categories,
project_types: version_project_types,
version_published_timestamp: version.date_published.timestamp(),
loader_fields: fields,
})
}));
let mut categories = project_categories.clone();
categories.extend(project_loaders.iter().cloned());
let mrpack_loaders = loader_fields
.get("mrpack_loaders")
.into_iter()
.flatten()
.filter_map(|value| value.as_str().map(String::from))
.collect::<Vec<_>>();
categories.extend(mrpack_loaders);
if loader_fields.contains_key("mrpack_loaders") {
categories.retain(|category| category != "mrpack");
}
categories.sort();
categories.dedup();
let (_, v2_og_project_type) =
LegacyProject::get_project_type(&project_types);
let (client_side, server_side) =
v2_reroute::convert_v3_side_types_to_v2_side_types(
&unvectorized_loader_fields,
Some(&v2_og_project_type),
);
if let Ok(client_side) = serde_json::to_value(client_side) {
loader_fields.insert("client_side".to_string(), vec![client_side]);
}
if let Ok(server_side) = serde_json::to_value(server_side) {
loader_fields.insert("server_side".to_string(), vec![server_side]);
}
let components = project
.components
.clone()
.into_query(ProjectId::from(project.id), &project_query_context)
.wrap_err("failed to populate query components")?;
let indexed_name = normalize_for_search(&project.name);
project_uploads.push(UploadSearchProject {
version_id: latest_version.map(|version| {
crate::models::ids::VersionId::from(version.id).to_string()
}),
project_id,
name: project.name,
indexed_name,
summary: project.summary,
categories,
project_categories,
display_categories,
follows: project.follows,
downloads: project.downloads,
log_downloads: (project.downloads.max(1) as f64).ln(),
icon_url: project.icon_url,
author: username.clone(),
author_id: ariadne::ids::UserId::from(user_id).to_string(),
organization: org_name,
organization_id: org_id.map(|id| {
crate::models::ids::OrganizationId::from(id).to_string()
}),
indexed_author: normalize_for_search(&username),
date_created: project.approved,
created_timestamp: project.approved.timestamp(),
date_modified: project.updated,
modified_timestamp: project.updated.timestamp(),
version_published_timestamp: latest_version
.map(|version| version.date_published.timestamp()),
license,
slug: project.slug,
project_types: project_types.clone(),
all_project_types: project_types,
gallery,
featured_gallery,
open_source,
color: project.color.map(|x| x as u32),
dependency_project_ids,
compatible_dependency_project_ids,
required_dependency_project_ids,
optional_dependency_project_ids,
embedded_dependency_project_ids,
incompatible_dependency_project_ids,
dependencies,
disclosure_types,
project_loader_fields,
loader_fields,
loaders: project_loaders,
components,
});
}
Ok(SearchDocumentBatch {
+6 -3
View File
@@ -247,7 +247,8 @@ impl FromStr for SearchBackendKind {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UploadSearchProject {
/// ID of the most recently published version.
pub version_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
pub project_id: String,
//
pub project_types: Vec<String>,
@@ -285,7 +286,8 @@ pub struct UploadSearchProject {
/// Unix timestamp of the last major modification
pub modified_timestamp: i64,
/// Unix timestamp of the most recently published version.
pub version_published_timestamp: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_published_timestamp: Option<i64>,
pub open_source: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<u32>,
@@ -369,7 +371,8 @@ pub struct SearchResults {
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct ResultSearchProject {
/// ID of the most recently published version.
pub version_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
pub project_id: String,
pub project_types: Vec<String>,
#[serde(default)]
+14
View File
@@ -1,5 +1,9 @@
use std::net::{AddrParseError, IpAddr, Ipv6Addr};
use actix_web::HttpRequest;
use crate::env::ENV;
pub fn convert_to_ip_v6(src: &str) -> Result<Ipv6Addr, AddrParseError> {
let ip_addr: IpAddr = src.parse()?;
@@ -23,3 +27,13 @@ pub fn strip_ip(ip: Ipv6Addr) -> u64 {
])
}
}
pub fn client_ip(req: &HttpRequest) -> Option<String> {
if ENV.CLOUDFLARE_INTEGRATION
&& let Some(header) = req.headers().get("CF-Connecting-IP")
{
return header.to_str().ok().map(str::to_owned);
}
req.connection_info().peer_addr().map(str::to_owned)
}
+1
View File
@@ -21,5 +21,6 @@ pub mod sentry;
pub mod tags;
pub mod tiltify;
pub mod time;
pub mod usercheck;
pub mod validate;
pub mod webhook;
+39 -1
View File
@@ -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 => {
+247
View File
@@ -0,0 +1,247 @@
use std::time::{Duration, Instant};
use eyre::{WrapErr, eyre};
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 {
Allow,
Block,
Challenge,
Unrecognized(String),
}
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",
DecisionAction::Block => "block",
DecisionAction::Challenge => "challenge",
DecisionAction::Unrecognized(other) => other,
}
}
}
impl<'de> Deserialize<'de> for DecisionAction {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(Self::from_api_value(&String::deserialize(deserializer)?))
}
}
#[derive(Deserialize)]
struct DecisionResponse {
decision: Decision,
#[serde(default)]
meta: Option<ResponseMeta>,
}
#[derive(Deserialize)]
struct Decision {
action: DecisionAction,
#[serde(default)]
matched_rule: Option<MatchedRule>,
}
#[derive(Deserialize)]
struct MatchedRule {
id: String,
name: String,
#[serde(default)]
message: Option<String>,
}
#[derive(Deserialize)]
struct ResponseMeta {
#[serde(default)]
request_id: Option<String>,
}
#[derive(Serialize)]
struct DecisionRequest<'a> {
email: &'a str,
}
/// Asks the configured UserCheck gate whether a signup should proceed.
///
/// Failure handling mirrors [`crate::util::neverbounce::check_email`]: a
/// transient failure resolves to `Allow` so an outage cannot block every
/// 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.
///
/// 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",
"UserCheck gate skipped because the API key or gate ID is not set",
);
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();
let response = match response {
Ok(response) => response,
Err(source) => {
let is_transient = is_transient(&source);
error!(
action = if is_transient { "allow" } else { "block" },
request.transient = is_transient,
request.time_ms = decision_time.as_millis(),
error = ?source,
"UserCheck gate decision failed",
);
if is_transient {
return Ok(DecisionAction::Allow);
}
return Err(eyre!(source)).wrap_err("failed to check email");
}
};
let DecisionResponse { decision, meta } = response;
let Decision {
action,
matched_rule,
} = decision;
let rule_id = matched_rule.as_ref().map(|rule| rule.id.as_str());
let rule_name = matched_rule.as_ref().map(|rule| rule.name.as_str());
let rule_message = matched_rule
.as_ref()
.and_then(|rule| rule.message.as_deref());
let request_id = meta.and_then(|meta| meta.request_id);
let time_ms = decision_time.as_millis();
match action {
DecisionAction::Unrecognized(ref value) => {
error!(
action = value.as_str(),
rule.id = rule_id,
rule.name = rule_name,
rule.message = rule_message,
request.id = request_id,
request.time_ms = time_ms,
"UserCheck gate returned an unrecognized action",
);
return Err(email_check_error_generic());
}
DecisionAction::Challenge => warn!(
action = action.as_str(),
rule.id = rule_id,
rule.name = rule_name,
rule.message = rule_message,
request.id = request_id,
request.time_ms = time_ms,
"UserCheck gate returned a challenge, allowing",
),
_ => debug!(
action = action.as_str(),
rule.id = rule_id,
rule.name = rule_name,
rule.message = rule_message,
request.id = request_id,
request.time_ms = time_ms,
"UserCheck gate decision succeeded",
),
}
redis
.connect()
.await?
.set(&cache_key, action.as_str(), Some(CACHE_EXPIRY_SECONDS))
.await?;
Ok(action)
}
pub fn gate_block_error() -> eyre::Error {
eyre!(
"Please try a different email address, or turn off any VPN or proxy services!"
)
}
async fn request_decision(email: &str) -> reqwest::Result<DecisionResponse> {
HTTP_CLIENT
.post(format!(
"{}/v0/gates/{}/decisions",
ENV.USERCHECK_BASE_URL.trim_end_matches('/'),
ENV.USERCHECK_GATE_ID,
))
.bearer_auth(&ENV.USERCHECK_API_KEY)
.timeout(TIMEOUT)
.json(&DecisionRequest { email })
.send()
.await?
.error_for_status()?
.json()
.await
}
fn is_transient(error: &reqwest::Error) -> bool {
if let Some(status) = error.status() {
return status.is_server_error()
|| matches!(
status,
StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS
);
}
error.is_timeout()
|| error.is_connect()
|| error.is_request()
|| error.is_body()
}