From 638c65a459cf49798e1ca95ee884954a6f47bcc1 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:58:27 -0400 Subject: [PATCH 01/39] feat(labrinth): user lock db schema --- ...e142f355db0252ff85de40b0052f6a4109bbe.json | 16 ++++ ...78da4fb58df880eb1d0d33a4cafe14e2a3b56.json | 22 ++++++ ...e4160a6e456f022a4f88c8e409f0441a30400.json | 14 ++++ .../migrations/20260928120000_user_locks.sql | 6 ++ apps/labrinth/src/database/models/mod.rs | 1 + .../src/database/models/user_lock_item.rs | 75 +++++++++++++++++++ 6 files changed, 134 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-423e54060fb4bc548ac6a5d4f01e142f355db0252ff85de40b0052f6a4109bbe.json create mode 100644 apps/labrinth/.sqlx/query-4f2874a54a427c8c3d1513b653d78da4fb58df880eb1d0d33a4cafe14e2a3b56.json create mode 100644 apps/labrinth/.sqlx/query-c2cc000527f4a47c7d7f44c2245e4160a6e456f022a4f88c8e409f0441a30400.json create mode 100644 apps/labrinth/migrations/20260928120000_user_locks.sql create mode 100644 apps/labrinth/src/database/models/user_lock_item.rs diff --git a/apps/labrinth/.sqlx/query-423e54060fb4bc548ac6a5d4f01e142f355db0252ff85de40b0052f6a4109bbe.json b/apps/labrinth/.sqlx/query-423e54060fb4bc548ac6a5d4f01e142f355db0252ff85de40b0052f6a4109bbe.json new file mode 100644 index 00000000000..5a8eb4eacd2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-423e54060fb4bc548ac6a5d4f01e142f355db0252ff85de40b0052f6a4109bbe.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_locks (user_id, locked_by, reason)\n VALUES ($1, $2, $3)\n ON CONFLICT (user_id) DO UPDATE\n SET locked_by = EXCLUDED.locked_by,\n reason = EXCLUDED.reason,\n created = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "423e54060fb4bc548ac6a5d4f01e142f355db0252ff85de40b0052f6a4109bbe" +} diff --git a/apps/labrinth/.sqlx/query-4f2874a54a427c8c3d1513b653d78da4fb58df880eb1d0d33a4cafe14e2a3b56.json b/apps/labrinth/.sqlx/query-4f2874a54a427c8c3d1513b653d78da4fb58df880eb1d0d33a4cafe14e2a3b56.json new file mode 100644 index 00000000000..eebeb413dc7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4f2874a54a427c8c3d1513b653d78da4fb58df880eb1d0d33a4cafe14e2a3b56.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM user_locks WHERE user_id = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4f2874a54a427c8c3d1513b653d78da4fb58df880eb1d0d33a4cafe14e2a3b56" +} diff --git a/apps/labrinth/.sqlx/query-c2cc000527f4a47c7d7f44c2245e4160a6e456f022a4f88c8e409f0441a30400.json b/apps/labrinth/.sqlx/query-c2cc000527f4a47c7d7f44c2245e4160a6e456f022a4f88c8e409f0441a30400.json new file mode 100644 index 00000000000..56a5c28b9c1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c2cc000527f4a47c7d7f44c2245e4160a6e456f022a4f88c8e409f0441a30400.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM user_locks WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c2cc000527f4a47c7d7f44c2245e4160a6e456f022a4f88c8e409f0441a30400" +} diff --git a/apps/labrinth/migrations/20260928120000_user_locks.sql b/apps/labrinth/migrations/20260928120000_user_locks.sql new file mode 100644 index 00000000000..57eb015de60 --- /dev/null +++ b/apps/labrinth/migrations/20260928120000_user_locks.sql @@ -0,0 +1,6 @@ +CREATE TABLE user_locks ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + locked_by BIGINT NOT NULL REFERENCES users(id), + reason TEXT NOT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 464604ebf34..d75f649b0af 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -36,6 +36,7 @@ pub mod team_item; pub mod thread_item; pub mod user_item; pub mod user_limits; +pub mod user_lock_item; pub mod user_preferences_item; pub mod user_subscription_item; pub mod users_compliance; diff --git a/apps/labrinth/src/database/models/user_lock_item.rs b/apps/labrinth/src/database/models/user_lock_item.rs new file mode 100644 index 00000000000..4209792676e --- /dev/null +++ b/apps/labrinth/src/database/models/user_lock_item.rs @@ -0,0 +1,75 @@ +use chrono::{DateTime, Utc}; +use eyre::{Result, WrapErr}; +use serde::{Deserialize, Serialize}; + +use crate::database::models::DBUserId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DBUserLock { + pub user_id: DBUserId, + pub locked_by: DBUserId, + pub reason: String, + pub created: DateTime, +} + +impl DBUserLock { + pub async fn upsert<'a, E>( + user_id: DBUserId, + locked_by: DBUserId, + reason: &str, + exec: E, + ) -> Result<()> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + sqlx::query!( + r#" + INSERT INTO user_locks (user_id, locked_by, reason) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE + SET locked_by = EXCLUDED.locked_by, + reason = EXCLUDED.reason, + created = NOW() + "#, + user_id as DBUserId, + locked_by as DBUserId, + reason, + ) + .execute(exec) + .await + .wrap_err("upserting user lock")?; + + Ok(()) + } + + pub async fn exists<'a, E>(user_id: DBUserId, exec: E) -> Result + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM user_locks WHERE user_id = $1) AS "exists!""#, + user_id as DBUserId, + ) + .fetch_one(exec) + .await + .wrap_err("checking user lock")?; + + Ok(exists) + } + + /// Returns `false` if the user was not locked. + pub async fn delete<'a, E>(user_id: DBUserId, exec: E) -> Result + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + "DELETE FROM user_locks WHERE user_id = $1", + user_id as DBUserId, + ) + .execute(exec) + .await + .wrap_err("deleting user lock")?; + + Ok(result.rows_affected() > 0) + } +} From 70d2e57904da11a45832795ca47a571e466390c3 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:30:43 -0400 Subject: [PATCH 02/39] feat(labrinth): check user lock state --- ...42db2fd13f36bca1d25dfe54bfc8991aac8c.json} | 22 +++- apps/labrinth/src/auth/checks.rs | 9 ++ apps/labrinth/src/auth/mod.rs | 6 +- apps/labrinth/src/auth/validate.rs | 18 ++- .../labrinth/src/database/models/user_item.rs | 30 ++++- apps/labrinth/src/models/v3/pats.rs | 35 ++++++ apps/labrinth/src/models/v3/users.rs | 23 ++++ .../labrinth/src/routes/internal/affiliate.rs | 5 +- apps/labrinth/src/routes/internal/billing.rs | 7 +- apps/labrinth/src/routes/internal/flows.rs | 14 ++- apps/labrinth/src/routes/internal/mod.rs | 2 + .../src/routes/internal/moderation/mod.rs | 4 +- .../routes/internal/moderation/user_lock.rs | 119 ++++++++++++++++++ apps/labrinth/src/routes/internal/session.rs | 5 + apps/labrinth/src/routes/v3/oauth_clients.rs | 10 +- apps/labrinth/src/routes/v3/users.rs | 19 ++- 16 files changed, 312 insertions(+), 16 deletions(-) rename apps/labrinth/.sqlx/{query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json => query-85b6b47438e5d6c00e119bc4c06b42db2fd13f36bca1d25dfe54bfc8991aac8c.json} (65%) create mode 100644 apps/labrinth/src/routes/internal/moderation/user_lock.rs diff --git a/apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json b/apps/labrinth/.sqlx/query-85b6b47438e5d6c00e119bc4c06b42db2fd13f36bca1d25dfe54bfc8991aac8c.json similarity index 65% rename from apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json rename to apps/labrinth/.sqlx/query-85b6b47438e5d6c00e119bc4c06b42db2fd13f36bca1d25dfe54bfc8991aac8c.json index 506d54fda7e..bda2c56393d 100644 --- a/apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json +++ b/apps/labrinth/.sqlx/query-85b6b47438e5d6c00e119bc4c06b42db2fd13f36bca1d25dfe54bfc8991aac8c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", + "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n users.created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at,\n user_locks.locked_by AS \"lock_locked_by?\",\n user_locks.reason AS \"lock_reason?\",\n user_locks.created AS \"lock_created?\"\n FROM users\n LEFT JOIN user_locks ON user_locks.user_id = users.id\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", "describe": { "columns": [ { @@ -142,6 +142,21 @@ "ordinal": 27, "name": "eligibility_verified_at", "type_info": "Timestamptz" + }, + { + "ordinal": 28, + "name": "lock_locked_by?", + "type_info": "Int8" + }, + { + "ordinal": 29, + "name": "lock_reason?", + "type_info": "Text" + }, + { + "ordinal": 30, + "name": "lock_created?", + "type_info": "Timestamptz" } ], "parameters": { @@ -178,8 +193,11 @@ true, false, false, + true, + true, + true, true ] }, - "hash": "3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6" + "hash": "85b6b47438e5d6c00e119bc4c06b42db2fd13f36bca1d25dfe54bfc8991aac8c" } diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index 0669ccfefd2..a691265de39 100644 --- a/apps/labrinth/src/auth/checks.rs +++ b/apps/labrinth/src/auth/checks.rs @@ -1,3 +1,4 @@ +use crate::auth::AuthenticationError; use crate::database; use crate::database::models::project_item::ProjectQueryResult; use crate::database::models::version_item::VersionQueryResult; @@ -27,6 +28,14 @@ pub fn require_verified_email(user: &User) -> Result<(), ApiError> { Ok(()) } +pub fn require_unlocked(user: &User) -> Result<(), ApiError> { + if user.lock.is_some() { + return Err(ApiError::Auth(AuthenticationError::AccountLocked.into())); + } + + Ok(()) +} + pub trait ValidateAuthorized { fn validate_authorized( &self, diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index 51599347811..28f884fa118 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -5,7 +5,7 @@ pub mod validate; pub use checks::{ filter_enlisted_projects_ids, filter_enlisted_version_ids, filter_visible_collections, filter_visible_project_ids, - filter_visible_projects, require_verified_email, + filter_visible_projects, require_unlocked, require_verified_email, }; use serde::{Deserialize, Serialize}; pub use validate::{ @@ -55,6 +55,8 @@ pub enum AuthenticationError { SocketError, #[error("Invalid callback URL specified")] Url, + #[error("Your account is locked and cannot perform this action")] + AccountLocked, } impl actix_web::ResponseError for AuthenticationError { @@ -83,6 +85,7 @@ impl actix_web::ResponseError for AuthenticationError { StatusCode::BAD_REQUEST } AuthenticationError::SocketError => StatusCode::BAD_REQUEST, + AuthenticationError::AccountLocked => StatusCode::UNAUTHORIZED, } } @@ -115,6 +118,7 @@ impl AuthenticationError { "provider_already_linked" } AuthenticationError::SocketError => "socket", + AuthenticationError::AccountLocked => "account_locked", } } } diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs index 46160e50487..1559011a910 100644 --- a/apps/labrinth/src/auth/validate.rs +++ b/apps/labrinth/src/auth/validate.rs @@ -68,7 +68,15 @@ where .ok_or_else(|| AuthenticationError::InvalidCredentials)?; if !scopes.contains(required_scopes) { - return Err(AuthenticationError::InvalidCredentials); + return Err( + if db_user.is_locked() + && required_scopes.intersects(Scopes::locked()) + { + AuthenticationError::AccountLocked + } else { + AuthenticationError::InvalidCredentials + }, + ); } Ok((scopes, db_user)) @@ -229,7 +237,13 @@ where _ => return Err(AuthenticationError::InvalidAuthMethod), }; - Ok(possible_user) + Ok(possible_user.map(|(scopes, user)| { + if user.is_locked() { + (scopes - Scopes::locked(), user) + } else { + (scopes, user) + } + })) } pub fn extract_authorization_header( diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index 06c6231927a..bf0d2d394af 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -3,6 +3,7 @@ use super::{DBCollectionId, DBReportId, DBThreadId}; use crate::database::models::DBOrganizationId; use crate::database::models::charge_item::DBCharge; use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::database::models::user_lock_item::DBUserLock; use crate::database::models::user_subscription_item::DBUserSubscription; use crate::database::{PgTransaction, models}; use crate::models::billing::ChargeStatus; @@ -61,6 +62,9 @@ pub struct DBUser { pub is_subscribed_to_newsletter: bool, pub eligibility_verified_at: Option>, + + #[serde(default)] + pub lock: Option, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -78,6 +82,10 @@ pub struct Pride26CampaignDonation { } impl DBUser { + pub fn is_locked(&self) -> bool { + self.lock.is_some() + } + pub async fn insert( &self, transaction: &mut PgTransaction<'_>, @@ -206,10 +214,10 @@ impl DBUser { .collect::>(); sqlx::query!( - " + r#" SELECT id, email, avatar_url, raw_avatar_url, username, bio, - created, role, badges, + users.created, role, badges, ( SELECT MAX(campaign_donations.donated_at) FROM campaign_donations @@ -223,10 +231,14 @@ impl DBUser { github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email, venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter, - eligibility_verified_at + eligibility_verified_at, + user_locks.locked_by AS "lock_locked_by?", + user_locks.reason AS "lock_reason?", + user_locks.created AS "lock_created?" FROM users + LEFT JOIN user_locks ON user_locks.user_id = users.id WHERE id = ANY($1) OR LOWER(username) = ANY($2) - ", + "#, &user_ids, &slugs, ) @@ -274,6 +286,16 @@ impl DBUser { allow_friend_requests: u.allow_friend_requests, is_subscribed_to_newsletter: u.is_subscribed_to_newsletter, eligibility_verified_at: u.eligibility_verified_at, + lock: u + .lock_locked_by + .zip(u.lock_reason) + .zip(u.lock_created) + .map(|((locked_by, reason), created)| DBUserLock { + user_id: DBUserId(u.id), + locked_by: DBUserId(locked_by), + reason, + created, + }), }; acc.insert(u.id, (Some(u.username), user)); diff --git a/apps/labrinth/src/models/v3/pats.rs b/apps/labrinth/src/models/v3/pats.rs index 7735c5e5398..8acb7861aa5 100644 --- a/apps/labrinth/src/models/v3/pats.rs +++ b/apps/labrinth/src/models/v3/pats.rs @@ -156,6 +156,41 @@ impl Scopes { self.intersects(Self::restricted()) } + /// Scopes withheld from users whose account is locked. + pub fn locked() -> Scopes { + Scopes::USER_WRITE + | Scopes::USER_AUTH_WRITE + | Scopes::NOTIFICATION_WRITE + | Scopes::PAYOUTS_WRITE + | Scopes::PROJECT_CREATE + | Scopes::PROJECT_WRITE + | Scopes::PROJECT_DELETE + | Scopes::VERSION_CREATE + | Scopes::VERSION_WRITE + | Scopes::VERSION_DELETE + | Scopes::REPORT_CREATE + | Scopes::REPORT_WRITE + | Scopes::REPORT_DELETE + | Scopes::THREAD_WRITE + | Scopes::PAT_CREATE + | Scopes::PAT_WRITE + | Scopes::PAT_DELETE + | Scopes::SESSION_DELETE + | Scopes::PERFORM_ANALYTICS + | Scopes::COLLECTION_CREATE + | Scopes::COLLECTION_WRITE + | Scopes::COLLECTION_DELETE + | Scopes::ORGANIZATION_CREATE + | Scopes::ORGANIZATION_WRITE + | Scopes::ORGANIZATION_DELETE + | Scopes::SHARED_INSTANCE_CREATE + | Scopes::SHARED_INSTANCE_WRITE + | Scopes::SHARED_INSTANCE_DELETE + | Scopes::SHARED_INSTANCE_VERSION_CREATE + | Scopes::SHARED_INSTANCE_VERSION_WRITE + | Scopes::SHARED_INSTANCE_VERSION_DELETE + } + pub fn parse_from_oauth_scopes( scopes: &str, ) -> Result { diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index b27f5f99c54..2e5592df2bd 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -70,6 +70,8 @@ pub struct User { pub eligibility_verified_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub moderation_notes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock: Option, pub github_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -78,6 +80,24 @@ pub struct User { pub steam_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserLock { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locked_by: Option, + pub reason: String, + pub created: DateTime, +} + +impl UserLock { + pub fn from_db(lock: DBUserLock, viewer_is_mod: bool) -> Self { + Self { + locked_by: viewer_is_mod.then(|| lock.locked_by.into()), + reason: lock.reason, + created: lock.created, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SearchUser { pub id: UserId, @@ -102,6 +122,7 @@ pub struct UserPayoutData { use crate::database::models::user_item::{ DBSearchUser, DBUser, Pride26CampaignDonation, }; +use crate::database::models::user_lock_item::DBUserLock; impl From for User { fn from(data: DBUser) -> Self { @@ -130,6 +151,7 @@ impl From for User { allow_friend_requests: None, eligibility_verified_at: None, moderation_notes: None, + lock: None, } } } @@ -200,6 +222,7 @@ impl User { allow_friend_requests: Some(db_user.allow_friend_requests), eligibility_verified_at: db_user.eligibility_verified_at, moderation_notes: None, + lock: db_user.lock.map(|lock| UserLock::from_db(lock, false)), } } } diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs index 8716dffba13..1eb88527c43 100644 --- a/apps/labrinth/src/routes/internal/affiliate.rs +++ b/apps/labrinth/src/routes/internal/affiliate.rs @@ -4,7 +4,7 @@ use xredis::RedisPool; use crate::database::PgPool; use crate::env::ENV; use crate::{ - auth::get_user_from_headers, + auth::{get_user_from_headers, require_unlocked}, database::models::{DBAffiliateCode, DBAffiliateCodeId, DBUser, DBUserId}, models::{ analytics::AffiliateCodeClick, ids::AffiliateCodeId, pats::Scopes, @@ -217,6 +217,7 @@ pub async fn create( ) .await .wrap_auth_err("authenticating API request")?; + require_unlocked(&creator)?; let is_admin = creator.role.is_admin(); let is_affiliate = creator.badges.contains(Badges::AFFILIATE); @@ -346,6 +347,7 @@ pub async fn delete( ) .await .wrap_auth_err("authenticating API request")?; + require_unlocked(&user)?; let (affiliate_code_id,) = path.into_inner(); let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); @@ -403,6 +405,7 @@ pub async fn patch( ) .await .wrap_auth_err("authenticating API request")?; + require_unlocked(&user)?; let (affiliate_code_id,) = path.into_inner(); let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 5b4f971c960..854b112b19e 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -1,6 +1,6 @@ use self::payments::*; use self::update_subscriptions::*; -use crate::auth::get_user_from_headers; +use crate::auth::{get_user_from_headers, require_unlocked}; use crate::database::models::charge_item::DBCharge; use crate::database::models::ids::DBUserSubscriptionId; use crate::database::models::notification_item::NotificationBuilder; @@ -715,6 +715,7 @@ pub async fn edit_subscription( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(&user)?; #[derive(Clone, Copy, PartialEq, Eq)] enum PaymentRequirement { @@ -1330,6 +1331,7 @@ pub async fn add_payment_method_flow( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(&user)?; let customer = get_or_create_customer( user.id, @@ -1392,6 +1394,7 @@ pub async fn edit_payment_method( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(&user)?; let (id,) = info.into_inner(); @@ -1466,6 +1469,7 @@ pub async fn remove_payment_method( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(&user)?; let (id,) = info.into_inner(); @@ -1744,6 +1748,7 @@ pub async fn initiate_payment( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(&user)?; let payment_request = payment_request.into_inner(); diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 9d576ba5e0e..c6f0755766d 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -327,6 +327,7 @@ impl TempUser { allow_friend_requests: true, is_subscribed_to_newsletter: sign_up_newsletter, eligibility_verified_at: Some(Utc::now()), + lock: None, } .insert(transaction) .await @@ -1728,6 +1729,10 @@ pub async fn discord_community_link( .wrap_auth_err("authenticating API request")? .1; + if db_user.is_locked() { + return Err(ApiError::Auth(AuthenticationError::AccountLocked.into())); + } + let Some(discord_id) = db_user.discord_id else { return Err(ApiError::Request(eyre!("discord account is not linked"))); }; @@ -2114,6 +2119,7 @@ impl ReadyAccountRegisterFlow { allow_friend_requests: true, is_subscribed_to_newsletter: register_flow.sign_up_newsletter, eligibility_verified_at: Some(Utc::now()), + lock: None, } .insert(transaction) .await; @@ -2912,7 +2918,7 @@ pub async fn reset_password_begin( id: user_id, email: user_email, .. - }) = user + }) = user.filter(|user| !user.is_locked()) { let flow = DBFlow::ForgotPassword { user_id } .insert(Duration::hours(24), &redis) @@ -2983,6 +2989,12 @@ pub async fn change_password( .ok_or_else(|| AuthenticationError::InvalidCredentials) .wrap_auth_err("fetching user from database")?; + if user.is_locked() { + return Err(ApiError::Auth( + AuthenticationError::AccountLocked.into(), + )); + } + Some(user) } else { return Err(ApiError::Auth(eyre::eyre!( diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index cf6301f11f1..ea06c015603 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -141,6 +141,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { moderation::external_license::add_file, moderation::external_license::reassign_file, moderation::external_license::update_license, + moderation::user_lock::lock_user, + moderation::user_lock::unlock_user, affiliate::ingest_click, affiliate::get_all, affiliate::create, diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index 979eac44820..d380aa8e1cc 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -30,6 +30,7 @@ use xredis::RedisPool; pub mod external_license; mod ownership; pub mod tech_review; +pub mod user_lock; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_projects) @@ -49,7 +50,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(web::scope("/tech-review").configure(tech_review::config)) .service( web::scope("/external-license").configure(external_license::config), - ); + ) + .service(web::scope("/user-lock").configure(user_lock::config)); } #[derive(Deserialize, utoipa::ToSchema)] diff --git a/apps/labrinth/src/routes/internal/moderation/user_lock.rs b/apps/labrinth/src/routes/internal/moderation/user_lock.rs new file mode 100644 index 00000000000..9622c6704fe --- /dev/null +++ b/apps/labrinth/src/routes/internal/moderation/user_lock.rs @@ -0,0 +1,119 @@ +use actix_web::{HttpRequest, delete, post, web}; +use eyre::eyre; +use serde::Deserialize; +use xredis::RedisPool; + +use crate::auth::check_is_moderator_from_headers; +use crate::database::PgPool; +use crate::database::models::DBUser; +use crate::database::models::user_lock_item::DBUserLock; +use crate::models::pats::Scopes; +use crate::models::users::Role; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::error::Context as _; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(lock_user).service(unlock_user); +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct LockUserRequest { + pub reason: String, +} + +/// Locks a user's account, preventing it from performing any write operations. +/// +/// Locking an already locked user replaces the existing lock's reason. +#[utoipa::path( + context_path = "/moderation/user-lock", + tag = "moderation", + request_body = LockUserRequest, + responses((status = NO_CONTENT)) +)] +#[post("/{id}")] +pub async fn lock_user( + req: HttpRequest, + path: web::Path<(String,)>, + body: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + let moderator = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let reason = body.reason.trim(); + if reason.is_empty() { + return Err(ApiError::Request(eyre!("lock reason must not be empty"))); + } + + let target = DBUser::get(&path.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("user not found")?; + + if Role::from_string(&target.role).is_mod() { + return Err(ApiError::Auth(eyre!("cannot lock a staff account"))); + } + + DBUserLock::upsert(target.id, moderator.id.into(), reason, &**pool) + .await + .wrap_internal_err("locking user")?; + + DBUser::clear_caches(&[(target.id, Some(target.username))], &redis) + .await + .wrap_internal_err("clearing user cache")?; + + Ok(()) +} + +/// Removes the lock from a user's account. +#[utoipa::path( + context_path = "/moderation/user-lock", + tag = "moderation", + responses((status = NO_CONTENT)) +)] +#[delete("/{id}")] +pub async fn unlock_user( + req: HttpRequest, + path: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let target = DBUser::get(&path.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("user not found")?; + + if !DBUserLock::delete(target.id, &**pool) + .await + .wrap_internal_err("unlocking user")? + { + return Err(ApiError::NotFound(eyre!("user is not locked"))); + } + + DBUser::clear_caches(&[(target.id, Some(target.username))], &redis) + .await + .wrap_internal_err("clearing user cache")?; + + Ok(()) +} diff --git a/apps/labrinth/src/routes/internal/session.rs b/apps/labrinth/src/routes/internal/session.rs index f7bd6f685c8..1c200bf5702 100644 --- a/apps/labrinth/src/routes/internal/session.rs +++ b/apps/labrinth/src/routes/internal/session.rs @@ -3,6 +3,7 @@ use crate::auth::{AuthenticationError, get_user_from_headers}; use crate::database::models::DBUserId; use crate::database::models::session_item::DBSession; use crate::database::models::session_item::SessionBuilder; +use crate::database::models::user_lock_item::DBUserLock; use crate::database::{PgPool, PgTransaction}; use crate::env::ENV; use crate::models::pats::Scopes; @@ -92,6 +93,10 @@ pub async fn issue_session( redis: &RedisPool, session_expires: Option>, ) -> Result { + if DBUserLock::exists(user_id, &mut *transaction).await? { + return Err(AuthenticationError::AccountLocked); + } + let metadata = get_session_metadata(&req).await?; let session = ChaCha20Rng::from_entropy() diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs index 339cb0ffdf5..a73fd4e56d2 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -10,7 +10,9 @@ use crate::file_hosting::FileHostPublicity; use crate::models::ids::OAuthClientId; use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::{ - auth::{checks::ValidateAuthorized, get_user_from_headers}, + auth::{ + checks::ValidateAuthorized, get_user_from_headers, require_unlocked, + }, database::models::{ DBOAuthClientId, DBUser, generate_oauth_client_id, generate_oauth_redirect_id, @@ -203,6 +205,7 @@ pub async fn oauth_client_create( ) .await? .1; + require_unlocked(¤t_user)?; new_oauth_app.validate().map_err(|e| { CreateError::ValidationError(validation_errors_to_string(e, None)) @@ -272,6 +275,7 @@ pub async fn oauth_client_delete( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(¤t_user)?; let client = DBOAuthClient::get(client_id.into_inner().into(), &**pool) .await @@ -342,6 +346,7 @@ pub async fn oauth_client_edit( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(¤t_user)?; client_updates .validate() @@ -447,6 +452,7 @@ pub async fn oauth_client_icon_edit( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(&user)?; let client = DBOAuthClient::get((*client_id).into(), &**pool) .await @@ -536,6 +542,7 @@ pub async fn oauth_client_icon_delete( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(&user)?; let client = DBOAuthClient::get((*client_id).into(), &**pool) .await @@ -641,6 +648,7 @@ pub async fn revoke_oauth_authorization( .await .wrap_auth_err("authenticating API request")? .1; + require_unlocked(¤t_user)?; DBOAuthClientAuthorization::remove( info.client_id.into(), diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 82128c697e7..416f5ea7bb9 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -22,7 +22,7 @@ use crate::{ organizations::Organization, pats::Scopes, projects::Project, - users::{Badges, Role, User}, + users::{Badges, Role, User, UserLock}, }, queue::session::AuthQueue, util::{img::delete_old_images, routes::read_limited_from_payload}, @@ -610,14 +610,21 @@ pub async fn users_get( HashMap::new() }; + let is_mod = auth_user.as_ref().is_some_and(|x| x.role.is_mod()); + let users: Vec = users_data .into_iter() .map(|data| { let mut user = crate::models::users::User::from(data.clone()); - if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { + let is_self = auth_user.as_ref().is_some_and(|x| x.id == user.id); + if is_mod { user.moderation_notes = Some(notes.get(&data.id).cloned().map(Into::into)); } + if is_mod || is_self { + user.lock = + data.lock.map(|lock| UserLock::from_db(lock, is_mod)); + } user }) .collect(); @@ -662,7 +669,11 @@ pub async fn user_get( let is_admin = auth_user.as_ref().is_some_and(|x| x.role.is_admin()); let is_mod = auth_user.as_ref().is_some_and(|x| x.role.is_mod()); + let is_self = auth_user + .as_ref() + .is_some_and(|x| x.id == UserId::from(data.id)); let user_id = data.id; + let lock = data.lock.clone(); let mut response: crate::models::users::User = if is_admin { let github_id = @@ -685,6 +696,10 @@ pub async fn user_get( response.moderation_notes = Some(note.map(Into::into)); } + if is_mod || is_self { + response.lock = lock.map(|lock| UserLock::from_db(lock, is_mod)); + } + Ok(HttpResponse::Ok().json(response)) } else { Err(ApiError::NotFound(eyre::eyre!("resource not found"))) From e0c69155afed1e977b2bcaae63bd047bd5bdb0ea Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:02:05 -0400 Subject: [PATCH 03/39] feat(frontend): user locking --- .../ui/banner/AccountLockedBanner.vue | 46 +++++++ apps/frontend/src/composables/auth.ts | 6 +- apps/frontend/src/layouts/default.vue | 5 + apps/frontend/src/locales/en-US/index.json | 9 ++ .../modules/labrinth/moderation/internal.ts | 17 +++ .../api-client/src/modules/labrinth/types.ts | 7 ++ .../ui/src/components/user/UserPageHeader.vue | 33 ++++- .../components/lock-user-modal.vue | 113 ++++++++++++++++++ .../layouts/shared/user-profile/layout.vue | 53 ++++++++ packages/ui/src/locales/en-US/index.json | 21 ++++ 10 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 apps/frontend/src/components/ui/banner/AccountLockedBanner.vue create mode 100644 packages/ui/src/layouts/shared/user-profile/components/lock-user-modal.vue diff --git a/apps/frontend/src/components/ui/banner/AccountLockedBanner.vue b/apps/frontend/src/components/ui/banner/AccountLockedBanner.vue new file mode 100644 index 00000000000..d95fb28e1de --- /dev/null +++ b/apps/frontend/src/components/ui/banner/AccountLockedBanner.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/frontend/src/composables/auth.ts b/apps/frontend/src/composables/auth.ts index 9c4c924b4c0..0672a8dcc45 100644 --- a/apps/frontend/src/composables/auth.ts +++ b/apps/frontend/src/composables/auth.ts @@ -6,7 +6,7 @@ import { rememberStoredAccount } from '@/composables/accounts.ts' import { useAuthCookie } from '@/composables/auth-cookie.ts' type AuthState = { - user: Labrinth.Users.v2.User | null + user: Labrinth.Users.v3.User | null token: string } @@ -134,7 +134,7 @@ export const initAuth = async ( }, }, true, - )) as Labrinth.Users.v2.User + )) as Labrinth.Users.v3.User } catch (error) { // only refresh when the token was rejected. not on timeouts or other errors (think this was the cause of random logouts) shouldRefresh = isAuthFailure(error) @@ -167,7 +167,7 @@ export const initAuth = async ( }, }, true, - )) as Labrinth.Users.v2.User + )) as Labrinth.Users.v3.User } catch (error) { if (isAuthFailure(error)) { clearAuthCookie(auth, authCookie) diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index eefd9b3b8b0..d9697689705 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -40,6 +40,10 @@ + @@ -889,6 +893,7 @@ import { useQuery } from '@tanstack/vue-query' import { getTaxThreshold } from '@/providers/creator-withdraw.ts' import TextLogo from '~/components/brand/TextLogo.vue' import BatchCreditModal from '~/components/ui/admin/BatchCreditModal.vue' +import AccountLockedBanner from '~/components/ui/banner/AccountLockedBanner.vue' import GeneratedStateErrorsBanner from '~/components/ui/banner/GeneratedStateErrorsBanner.vue' import PreviewBanner from '~/components/ui/banner/PreviewBanner.vue' import RussiaBanner from '~/components/ui/banner/RussiaBanner.vue' diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index c8a43b62471..67267131902 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -2738,6 +2738,15 @@ "layout.banner.account-action": { "message": "Account action required" }, + "layout.banner.account-locked.description": { + "message": "A moderator has locked your account, so you cannot create, edit, or delete anything on Modrinth. Reason: {reason}" + }, + "layout.banner.account-locked.support": { + "message": "Contact support" + }, + "layout.banner.account-locked.title": { + "message": "Your account has been locked" + }, "layout.banner.add-email.button": { "message": "Visit account settings" }, diff --git a/packages/api-client/src/modules/labrinth/moderation/internal.ts b/packages/api-client/src/modules/labrinth/moderation/internal.ts index 2ce4015ccf1..42b1149aa0a 100644 --- a/packages/api-client/src/modules/labrinth/moderation/internal.ts +++ b/packages/api-client/src/modules/labrinth/moderation/internal.ts @@ -87,6 +87,23 @@ export class LabrinthModerationInternalModule extends AbstractModule { ) } + public async lockUser(userId: string, reason: string): Promise { + return this.client.request(`/moderation/user-lock/${userId}`, { + api: 'labrinth', + version: 'internal', + method: 'POST', + body: { reason }, + }) + } + + public async unlockUser(userId: string): Promise { + return this.client.request(`/moderation/user-lock/${userId}`, { + api: 'labrinth', + version: 'internal', + method: 'DELETE', + }) + } + public async setProjectJudgements( judgements: Labrinth.Moderation.Internal.ProjectJudgements, ): Promise { diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 11275013555..28f86511899 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1764,6 +1764,12 @@ export namespace Labrinth { user_rating: number version: number } + + export type UserLock = { + locked_by?: string + reason: string + created: string + } } export namespace v2 { @@ -1895,6 +1901,7 @@ export namespace Labrinth { stripe_customer_id?: string allow_friend_requests?: boolean moderation_notes?: Common.ModerationNote | null + lock?: Common.UserLock github_id?: number discord_id?: string steam_id?: string diff --git a/packages/ui/src/components/user/UserPageHeader.vue b/packages/ui/src/components/user/UserPageHeader.vue index fa14db78368..5f15798fdd1 100644 --- a/packages/ui/src/components/user/UserPageHeader.vue +++ b/packages/ui/src/components/user/UserPageHeader.vue @@ -11,7 +11,7 @@ /> -