diff --git a/.sqlx/query-eab69a8d679b2dcfb06a9ccc79851f6c693d0c372142ff0e7ded89c95e4bcf44.json b/.sqlx/query-3d746dcb479005f1f4ae5e157a5ace7626eda4849d44c6c2d7079fd346eb4edc.json similarity index 65% rename from .sqlx/query-eab69a8d679b2dcfb06a9ccc79851f6c693d0c372142ff0e7ded89c95e4bcf44.json rename to .sqlx/query-3d746dcb479005f1f4ae5e157a5ace7626eda4849d44c6c2d7079fd346eb4edc.json index b5a69a9811..7f2f3d0730 100644 --- a/.sqlx/query-eab69a8d679b2dcfb06a9ccc79851f6c693d0c372142ff0e7ded89c95e4bcf44.json +++ b/.sqlx/query-3d746dcb479005f1f4ae5e157a5ace7626eda4849d44c6c2d7079fd346eb4edc.json @@ -1,6 +1,6 @@ { "db_name": "MySQL", - "query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ", + "query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "eab69a8d679b2dcfb06a9ccc79851f6c693d0c372142ff0e7ded89c95e4bcf44" + "hash": "3d746dcb479005f1f4ae5e157a5ace7626eda4849d44c6c2d7079fd346eb4edc" } diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/update/set_user_ban_status.rs b/crates/schema/database/mysql_queries/src/queries/users/user/update/set_user_ban_status.rs index 562dd8bdfa..4d4070b220 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user/update/set_user_ban_status.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user/update/set_user_ban_status.rs @@ -1,20 +1,27 @@ -use anyhow::anyhow; -use sqlx::MySqlPool; +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; -use errors::AnyhowResult; use tokens::tokens::users::UserToken; -pub struct SetUserBanStatsArgs<'a> { +pub struct SetUserBanStatusArgs<'a, 'c: 'a, E> + where E: 'a + Executor<'c, Database = MySql> +{ pub subject_user_token: &'a UserToken, pub is_banned: bool, pub mod_user_token: &'a UserToken, pub maybe_mod_comments: Option<&'a str>, - pub mysql_pool: &'a MySqlPool, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, } -pub async fn set_user_ban_status(args: SetUserBanStatsArgs<'_>) -> AnyhowResult<()> { - let query_result = sqlx::query!( - r#" +pub async fn set_user_ban_status<'a, 'c, E>( + args: SetUserBanStatusArgs<'a, 'c, E>, +) -> Result<(), sqlx::Error> + where E: 'a + Executor<'c, Database = MySql> +{ + sqlx::query!( + r#" UPDATE users SET is_banned = ?, @@ -24,17 +31,14 @@ SET WHERE users.token = ? LIMIT 1 - "#, - args.is_banned, - args.maybe_mod_comments, - args.mod_user_token, - args.subject_user_token, - ) - .execute(args.mysql_pool) - .await; + "#, + args.is_banned, + args.maybe_mod_comments, + args.mod_user_token, + args.subject_user_token, + ) + .execute(args.mysql_executor) + .await?; - match query_result { - Ok(_) => Ok(()), - Err(err) => Err(anyhow!("error with query: {:?}", err)), - } + Ok(()) } diff --git a/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs index 486a079c9b..9c4bfefecf 100644 --- a/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs +++ b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs @@ -20,6 +20,14 @@ pub enum StaffAuditAction { /// Staff redeemed an impersonation request, creating a session. #[serde(rename = "impersonate_user_redeem")] ImpersonateUserRedeem, + + /// Staff banned a user. + #[serde(rename = "ban_user")] + BanUser, + + /// Staff unbanned a user. + #[serde(rename = "unban_user")] + UnbanUser, } impl_enum_display_and_debug_using_to_str!(StaffAuditAction); @@ -31,6 +39,8 @@ impl StaffAuditAction { match self { Self::ImpersonateUserRequest => "impersonate_user_request", Self::ImpersonateUserRedeem => "impersonate_user_redeem", + Self::BanUser => "ban_user", + Self::UnbanUser => "unban_user", } } @@ -38,6 +48,8 @@ impl StaffAuditAction { match value { "impersonate_user_request" => Ok(Self::ImpersonateUserRequest), "impersonate_user_redeem" => Ok(Self::ImpersonateUserRedeem), + "ban_user" => Ok(Self::BanUser), + "unban_user" => Ok(Self::UnbanUser), _ => Err(format!("invalid StaffAuditAction value: {:?}", value)), } } @@ -46,6 +58,8 @@ impl StaffAuditAction { BTreeSet::from([ Self::ImpersonateUserRequest, Self::ImpersonateUserRedeem, + Self::BanUser, + Self::UnbanUser, ]) } } @@ -62,24 +76,30 @@ mod tests { fn test_serialization() { assert_serialization(StaffAuditAction::ImpersonateUserRequest, "impersonate_user_request"); assert_serialization(StaffAuditAction::ImpersonateUserRedeem, "impersonate_user_redeem"); + assert_serialization(StaffAuditAction::BanUser, "ban_user"); + assert_serialization(StaffAuditAction::UnbanUser, "unban_user"); } #[test] fn to_str() { assert_eq!(StaffAuditAction::ImpersonateUserRequest.to_str(), "impersonate_user_request"); assert_eq!(StaffAuditAction::ImpersonateUserRedeem.to_str(), "impersonate_user_redeem"); + assert_eq!(StaffAuditAction::BanUser.to_str(), "ban_user"); + assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user"); } #[test] fn from_str() { assert_eq!(StaffAuditAction::from_str("impersonate_user_request").unwrap(), StaffAuditAction::ImpersonateUserRequest); assert_eq!(StaffAuditAction::from_str("impersonate_user_redeem").unwrap(), StaffAuditAction::ImpersonateUserRedeem); + assert_eq!(StaffAuditAction::from_str("ban_user").unwrap(), StaffAuditAction::BanUser); + assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser); assert!(StaffAuditAction::from_str("invalid").is_err()); } #[test] fn all_variants() { - const EXPECTED_COUNT: usize = 2; + const EXPECTED_COUNT: usize = 4; assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT); } } diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 5923fd3c74..66d4f75d9d 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -87,6 +87,7 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file: use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*; use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::*; use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::*; +use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*; use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*; use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::*; use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::*; @@ -512,6 +513,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler, crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler, crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler, + crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler, @@ -947,6 +949,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux MediaFilesByBatchListItem, ModelWeightForList, ModelWeightSearchResult, + ModerationBanUserRequest, + ModerationBanUserSuccessResponse, ModerationImpersonateRequest, ModerationImpersonateSuccessResponse, ModernInferenceQueueStats, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/ban_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/ban_user.rs deleted file mode 100644 index 707f8cd0d6..0000000000 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/ban_user.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::fmt; -use std::sync::Arc; - -use actix_web::error::ResponseError; -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse}; -use log::warn; - -use mysql_queries::queries::users::user::update::set_user_ban_status::{set_user_ban_status, SetUserBanStatsArgs}; -use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username; - -use crate::http_server::web_utils::response_success_helpers::simple_json_success; -use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error; -use crate::state::server_state::ServerState; - -#[derive(Deserialize)] -pub struct BanUserRequest { - pub username: String, - pub mod_notes: String, - pub is_banned: bool, -} - -#[derive(Serialize)] -pub struct BanUserSuccessResponse { - pub success: bool, -} - -#[derive(Serialize, Debug)] -pub struct BanUserErrorResponse { - pub success: bool, - pub error_type: BanUserErrorType, - pub error_message: String, -} - -#[derive(Copy, Clone, Debug, Serialize)] -pub enum BanUserErrorType { - ServerError, - Unauthorized, - UserNotFound, -} - -impl BanUserErrorResponse { - fn unauthorized() -> Self { - Self { - success: false, - error_type: BanUserErrorType::Unauthorized, - error_message: "unauthorized".to_string() - } - } - fn server_error() -> Self { - Self { - success: false, - error_type: BanUserErrorType::ServerError, - error_message: "server error".to_string() - } - } - fn not_found() -> Self { - Self { - success: false, - error_type: BanUserErrorType::UserNotFound, - error_message: "user not found".to_string() - } - } -} - -impl ResponseError for BanUserErrorResponse { - fn status_code(&self) -> StatusCode { - match self.error_type { - BanUserErrorType::ServerError => StatusCode::INTERNAL_SERVER_ERROR, - BanUserErrorType::Unauthorized => StatusCode::UNAUTHORIZED, - BanUserErrorType::UserNotFound => StatusCode::NOT_FOUND, - } - } - - fn error_response(&self) -> HttpResponse { - serialize_as_json_error(self) - } -} - -// NB: Not using DeriveMore since Clion doesn't understand it. -impl fmt::Display for BanUserErrorResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self.error_type) - } -} - -pub async fn ban_user_handler( - http_request: HttpRequest, - request: web::Json, - server_state: web::Data> -) -> Result { - - let maybe_user_session = server_state - .session_checker - .maybe_get_user_session(&http_request, &server_state.mysql_pool) - .await - .map_err(|e| { - warn!("Session checker error: {:?}", e); - BanUserErrorResponse::server_error() - })?; - - let user_session = match maybe_user_session { - Some(session) => session, - None => { - warn!("not logged in"); - return Err(BanUserErrorResponse::unauthorized()); - } - }; - - if !user_session.can_ban_users { - warn!("user is not allowed to add bans: {:?}", user_session.user_token); - return Err(BanUserErrorResponse::unauthorized()); - } - - let username_lower = request.username.to_lowercase(); - - let user_lookup_result = get_user_profile_by_username( - &username_lower, &server_state.mysql_pool).await; - - let user_profile = match user_lookup_result { - Ok(Some(result)) => result, - Ok(None) => return Err(BanUserErrorResponse::not_found()), - Err(err) => { - warn!("lookup error: {:?}", err); - return Err(BanUserErrorResponse::server_error()); - } - }; - - set_user_ban_status(SetUserBanStatsArgs { - subject_user_token: &user_profile.user_token, - is_banned: request.is_banned, - mod_user_token: &user_session.user_token, - maybe_mod_comments: Some(&request.mod_notes), - mysql_pool: &server_state.mysql_pool, - }).await - .map_err(|err| { - warn!("Add IP ban DB error: {:?}", err); - BanUserErrorResponse::server_error() - })?; - - Ok(simple_json_success()) -} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/mod.rs index 513f013b35..873666a9eb 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/mod.rs @@ -1 +1 @@ -pub mod ban_user; \ No newline at end of file +pub mod moderation_ban_user_handler; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/moderation_ban_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/moderation_ban_user_handler.rs new file mode 100644 index 0000000000..274ca2fc1a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_bans/moderation_ban_user_handler.rs @@ -0,0 +1,158 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{info, warn}; +use utoipa::ToSchema; + +use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction; +use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{ + insert_staff_audit_log, InsertStaffAuditLogArgs, +}; +use mysql_queries::queries::users::user::update::set_user_ban_status::{ + set_user_ban_status, SetUserBanStatusArgs, +}; +use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::web_utils::user_session::require_moderator::{ + require_moderator, UseDatabase, +}; +use crate::state::server_state::ServerState; + +// --- Request --- + +#[derive(Deserialize, ToSchema)] +pub struct ModerationBanUserRequest { + pub username: String, + pub mod_notes: String, + pub is_banned: bool, +} + +// --- Response --- + +#[derive(Serialize, ToSchema)] +pub struct ModerationBanUserSuccessResponse { + pub success: bool, +} + +// --- Handler --- + +/// Ban or unban a user. Moderators only. +#[utoipa::path( + post, + tag = "Moderation", + path = "/moderation/user_bans/manage_ban", + request_body = ModerationBanUserRequest, + responses( + (status = 200, description = "User ban status updated", body = ModerationBanUserSuccessResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + (status = 500, description = "Server error"), + ), +)] +pub async fn moderation_ban_user_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, AdvancedCommonWebError> { + + // 1. Require moderator with ban permissions. + let user_session = require_moderator( + &http_request, + &server_state, + UseDatabase::GrabNewConnection, + ).await.map_err(|err| { + warn!("Moderator check failed: {:?}", err); + AdvancedCommonWebError::NotAuthorized + })?; + + if !user_session.can_ban_users { + warn!("User {} is not allowed to ban users", user_session.user_token.as_str()); + return Err(AdvancedCommonWebError::NotAuthorized); + } + + // 2. Look up the target user by username. + let username_lower = request.username.to_lowercase(); + + let user_profile = get_user_profile_by_username( + &username_lower, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("User lookup error: {:?}", err); + AdvancedCommonWebError::from_anyhow_error(err) + })?.ok_or_else(|| { + warn!("User not found for ban: {}", username_lower); + AdvancedCommonWebError::NotFound + })?; + + let ip_address = get_request_ip(&http_request); + + let audit_action = if request.is_banned { + StaffAuditAction::BanUser + } else { + StaffAuditAction::UnbanUser + }; + + info!( + "Moderator {} {} user {} ({})", + user_session.user_token.as_str(), + audit_action.to_str(), + user_profile.user_token.as_str(), + username_lower, + ); + + // 3. Begin transaction: ban + audit log. + let mut transaction = server_state.mysql_pool.begin().await + .map_err(|err| { + warn!("Failed to begin transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // 4. Set ban status. + set_user_ban_status(SetUserBanStatusArgs { + subject_user_token: &user_profile.user_token, + is_banned: request.is_banned, + mod_user_token: &user_session.user_token, + maybe_mod_comments: Some(&request.mod_notes), + mysql_executor: &mut *transaction, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Failed to set user ban status: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // 5. Insert staff audit log. + let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs { + audit_action, + maybe_entity_type: Some(StaffAuditEntityType::User), + maybe_entity_token: Some(user_profile.user_token.as_str()), + staff_user_token: &user_session.user_token, + actor_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Failed to insert staff audit log: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // 6. Commit transaction. + transaction.commit().await.map_err(|err| { + warn!("Failed to commit transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + info!( + "User {} ban status set to {} by moderator {}", + user_profile.user_token.as_str(), + request.is_banned, + user_session.user_token.as_str(), + ); + + Ok(Json(ModerationBanUserSuccessResponse { + success: true, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs index d83a7132af..779b187e9b 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs @@ -29,7 +29,7 @@ use crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_han use crate::http_server::endpoints::moderation::jobs::get_tts_inference_queue_count::get_tts_inference_queue_count_handler; use crate::http_server::endpoints::moderation::jobs::get_w2l_inference_queue_count::get_w2l_inference_queue_count_handler; use crate::http_server::endpoints::moderation::jobs::kill_tts_inference_jobs::kill_tts_inference_jobs_handler; -use crate::http_server::endpoints::moderation::user_bans::ban_user::ban_user_handler; +use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler; use crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler; use crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler; use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler; @@ -183,7 +183,7 @@ pub fn add_moderator_routes (app: App) -> App web::scope("/user_bans") .service( web::resource("/manage_ban") - .route(web::post().to(ban_user_handler)) + .route(web::post().to(moderation_ban_user_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) )