Compare commits

...
Author SHA1 Message Date
Brandon Thomas 2f38d71e8e codgen 2026-04-14 03:18:41 -04:00
Brandon Thomas 079ccacd51 ban users handler 2026-04-14 03:18:25 -04:00
8 changed files with 212 additions and 168 deletions
@@ -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"
}
@@ -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(())
}
@@ -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);
}
}
@@ -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,
@@ -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<BanUserRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<HttpResponse, BanUserErrorResponse> {
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())
}
@@ -1 +1 @@
pub mod ban_user;
pub mod moderation_ban_user_handler;
@@ -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<ModerationBanUserRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationBanUserSuccessResponse>, 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,
}))
}
@@ -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<T, B> (app: App<T>) -> App<T>
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()))
)
)