mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
Merge branch 'main' into boris/dev-1126-v2
This commit is contained in:
Generated
-23
@@ -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"
|
||||
}
|
||||
Generated
+23
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
Generated
+15
@@ -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"
|
||||
}
|
||||
Generated
+24
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -100,7 +100,7 @@ impl BackgroundTask {
|
||||
}
|
||||
IncrementalIndexSearch => {
|
||||
crate::search::incremental::consume::run(
|
||||
pool,
|
||||
ro_pool,
|
||||
redis_pool,
|
||||
search_backend,
|
||||
kafka_client,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) = ¶ms.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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user