diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_profiles/lookup_user_for_moderation.rs b/crates/schema/database/mysql_queries/src/queries/users/user_profiles/lookup_user_for_moderation.rs new file mode 100644 index 0000000000..c87a9bc840 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user_profiles/lookup_user_for_moderation.rs @@ -0,0 +1,231 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use log::warn; +use sqlx::MySqlPool; + +use errors::AnyhowResult; +use tokens::tokens::users::UserToken; + +use crate::helpers::boolean_converters::i8_to_bool; + +pub struct LookupUserForModerationResult { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub username_is_generated: bool, + pub is_temporary: bool, + pub username_is_not_customized: bool, + pub email_address: String, + pub email_confirmed: bool, + pub email_is_synthetic: bool, + pub is_without_password: bool, + pub ip_address_creation: String, + pub ip_address_last_login: String, + pub maybe_avatar_media_file_token: Option, + pub email_gravatar_hash: String, + pub created_at: DateTime, +} + +struct RawLookupUserRecord { + user_token: UserToken, + username: String, + display_name: String, + username_is_generated: i8, + is_temporary: i8, + username_is_not_customized: i8, + email_address: String, + email_confirmed: i8, + email_is_synthetic: i8, + is_without_password: i8, + ip_address_creation: String, + ip_address_last_login: String, + maybe_avatar_media_file_token: Option, + email_gravatar_hash: String, + created_at: DateTime, +} + +impl From for LookupUserForModerationResult { + fn from(raw: RawLookupUserRecord) -> Self { + Self { + user_token: raw.user_token, + username: raw.username, + display_name: raw.display_name, + username_is_generated: i8_to_bool(raw.username_is_generated), + is_temporary: i8_to_bool(raw.is_temporary), + username_is_not_customized: i8_to_bool(raw.username_is_not_customized), + email_address: raw.email_address, + email_confirmed: i8_to_bool(raw.email_confirmed), + email_is_synthetic: i8_to_bool(raw.email_is_synthetic), + is_without_password: i8_to_bool(raw.is_without_password), + ip_address_creation: raw.ip_address_creation, + ip_address_last_login: raw.ip_address_last_login, + maybe_avatar_media_file_token: raw.maybe_avatar_media_file_token, + email_gravatar_hash: raw.email_gravatar_hash, + created_at: raw.created_at, + } + } +} + +const QUERY: &str = r#" +SELECT + users.token as `user_token: tokens::tokens::users::UserToken`, + username, + display_name, + username_is_generated, + is_temporary, + username_is_not_customized, + email_address, + email_confirmed, + email_is_synthetic, + is_without_password, + ip_address_creation, + ip_address_last_login, + maybe_avatar_media_file_token, + email_gravatar_hash, + created_at +FROM users +WHERE + users.token = ? + AND users.user_deleted_at IS NULL + AND users.mod_deleted_at IS NULL +LIMIT 1 +"#; + +pub async fn lookup_user_for_moderation_by_token( + token: &str, + mysql_pool: &MySqlPool, +) -> AnyhowResult> { + let result = sqlx::query_as!( + RawLookupUserRecord, + r#" +SELECT + users.token as `user_token: tokens::tokens::users::UserToken`, + username, + display_name, + username_is_generated, + is_temporary, + username_is_not_customized, + email_address, + email_confirmed, + email_is_synthetic, + is_without_password, + ip_address_creation, + ip_address_last_login, + maybe_avatar_media_file_token, + email_gravatar_hash, + created_at +FROM users +WHERE + users.token = ? + AND users.user_deleted_at IS NULL + AND users.mod_deleted_at IS NULL +LIMIT 1 + "#, + token, + ) + .fetch_one(mysql_pool) + .await; + + match result { + Ok(record) => Ok(Some(record.into())), + Err(sqlx::Error::RowNotFound) => Ok(None), + Err(err) => { + warn!("lookup_user_for_moderation_by_token query error: {:?}", err); + Err(anyhow!("query error")) + } + } +} + +pub async fn lookup_user_for_moderation_by_email( + email: &str, + mysql_pool: &MySqlPool, +) -> AnyhowResult> { + let email = email.trim().to_lowercase(); + + let result = sqlx::query_as!( + RawLookupUserRecord, + r#" +SELECT + users.token as `user_token: tokens::tokens::users::UserToken`, + username, + display_name, + username_is_generated, + is_temporary, + username_is_not_customized, + email_address, + email_confirmed, + email_is_synthetic, + is_without_password, + ip_address_creation, + ip_address_last_login, + maybe_avatar_media_file_token, + email_gravatar_hash, + created_at +FROM users +WHERE + users.email_address = ? + AND users.user_deleted_at IS NULL + AND users.mod_deleted_at IS NULL +LIMIT 1 + "#, + email, + ) + .fetch_one(mysql_pool) + .await; + + match result { + Ok(record) => Ok(Some(record.into())), + Err(sqlx::Error::RowNotFound) => Ok(None), + Err(err) => { + warn!("lookup_user_for_moderation_by_email query error: {:?}", err); + Err(anyhow!("query error")) + } + } +} + +pub async fn lookup_user_for_moderation_by_username( + username: &str, + mysql_pool: &MySqlPool, +) -> AnyhowResult> { + let username = username.trim().to_lowercase(); + + let result = sqlx::query_as!( + RawLookupUserRecord, + r#" +SELECT + users.token as `user_token: tokens::tokens::users::UserToken`, + username, + display_name, + username_is_generated, + is_temporary, + username_is_not_customized, + email_address, + email_confirmed, + email_is_synthetic, + is_without_password, + ip_address_creation, + ip_address_last_login, + maybe_avatar_media_file_token, + email_gravatar_hash, + created_at +FROM users +WHERE + users.username = ? + AND users.user_deleted_at IS NULL + AND users.mod_deleted_at IS NULL +LIMIT 1 + "#, + username, + ) + .fetch_one(mysql_pool) + .await; + + match result { + Ok(record) => Ok(Some(record.into())), + Err(sqlx::Error::RowNotFound) => Ok(None), + Err(err) => { + warn!("lookup_user_for_moderation_by_username query error: {:?}", err); + Err(anyhow!("query error")) + } + } +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_profiles/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user_profiles/mod.rs index c7c64baa01..06a79a5238 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_profiles/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_profiles/mod.rs @@ -1,4 +1,5 @@ pub mod edit_user_profile_as_account_holder; pub mod edit_user_profile_as_mod; pub mod get_user_profile_by_token; -pub mod get_user_profile_by_username; \ No newline at end of file +pub mod get_user_profile_by_username; +pub mod lookup_user_for_moderation; \ No newline at end of file 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 673813e6f4..93ae367b71 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -265,6 +265,7 @@ use artcraft_api_defs::subscriptions::get_session_subscription::*; use artcraft_api_defs::media_file::list_batch_generated_media_files::*; // Handler modules with locally-defined types use crate::http_server::endpoints::moderation::info::moderator_token_info_handler::*; +use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::*; use crate::http_server::endpoints::webhooks::fal_webhook_handler::*; use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::*; use crate::http_server::endpoints::voice_conversion::enqueue_seed_vc_inference_handler::*; @@ -434,6 +435,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::tts::enqueue_infer_f5_tts_handler::enqueue_infer_f5_tts_handler::enqueue_infer_f5_tts_handler, // Moderation crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler, + crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler, // Credits crate::http_server::endpoints::credits::get_session_credits_handler::get_session_credits_handler, // Subscriptions @@ -611,6 +613,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux EditUserFeatureFlagsError, EditUserFeatureFlagsOption, EditUserFeatureFlagsRequest, + ModeratorUserLookupRequest, + ModeratorUserLookupSuccessResponse, + ModeratorUserLookupUserDetails, + ModeratorUserLookupError, GptImage1EditImageRequest, GptImage1EditImageImageSize, GptImage1EditImageNumImages, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs index c107a68107..7d83d79318 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs @@ -1,5 +1,6 @@ pub mod info; pub mod ip_bans; pub mod jobs; +pub mod user; pub mod user_bans; pub mod user_feature_flags; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/mod.rs new file mode 100644 index 0000000000..b024cbf035 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/mod.rs @@ -0,0 +1 @@ +pub mod moderator_user_lookup_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_handler.rs new file mode 100644 index 0000000000..4c503afb90 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user/moderator_user_lookup_handler.rs @@ -0,0 +1,163 @@ +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 utoipa::ToSchema; + +use mysql_queries::queries::users::user_profiles::lookup_user_for_moderation::{ + lookup_user_for_moderation_by_email, + lookup_user_for_moderation_by_token, + lookup_user_for_moderation_by_username, + LookupUserForModerationResult, +}; +use tokens::tokens::users::UserToken; + +use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error; +use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError, UseDatabase}; +use crate::state::server_state::ServerState; + +const LEGACY_USER_TOKEN_PREFIX: &str = "U:"; + +#[derive(Deserialize, ToSchema)] +pub struct ModeratorUserLookupRequest { + pub search: String, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserLookupSuccessResponse { + pub success: bool, + pub maybe_user: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorUserLookupUserDetails { + pub token: String, + pub username: String, + pub display_name: String, + pub username_is_generated: bool, + pub is_temporary: bool, + pub username_is_not_customized: bool, + pub email_address: String, + pub email_confirmed: bool, + pub email_is_synthetic: bool, + pub is_without_password: bool, + pub ip_address_creation: String, + pub ip_address_last_login: String, + pub maybe_avatar_media_file_token: Option, + pub email_gravatar_hash: String, +} + +impl From for ModeratorUserLookupUserDetails { + fn from(result: LookupUserForModerationResult) -> Self { + Self { + token: result.user_token.to_string(), + username: result.username, + display_name: result.display_name, + username_is_generated: result.username_is_generated, + is_temporary: result.is_temporary, + username_is_not_customized: result.username_is_not_customized, + email_address: result.email_address, + email_confirmed: result.email_confirmed, + email_is_synthetic: result.email_is_synthetic, + is_without_password: result.is_without_password, + ip_address_creation: result.ip_address_creation, + ip_address_last_login: result.ip_address_last_login, + maybe_avatar_media_file_token: result.maybe_avatar_media_file_token, + email_gravatar_hash: result.email_gravatar_hash, + } + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub enum ModeratorUserLookupError { + ServerError, + Unauthorized, +} + +impl ResponseError for ModeratorUserLookupError { + fn status_code(&self) -> StatusCode { + match *self { + ModeratorUserLookupError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + ModeratorUserLookupError::Unauthorized => StatusCode::UNAUTHORIZED, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +impl fmt::Display for ModeratorUserLookupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +#[utoipa::path( + post, + tag = "Moderation", + path = "/v1/moderation/user_lookup", + request_body = ModeratorUserLookupRequest, + responses( + (status = 200, description = "Success", body = ModeratorUserLookupSuccessResponse), + (status = 401, description = "Unauthorized", body = ModeratorUserLookupError), + (status = 500, description = "Server error", body = ModeratorUserLookupError), + ), +)] +pub async fn moderator_user_lookup_handler( + http_request: HttpRequest, + request: web::Json, + server_state: web::Data>, +) -> Result { + + let _user_session = require_moderator(&http_request, &server_state, UseDatabase::Implicit) + .await + .map_err(|err| match err { + RequireModeratorError::ServerError => ModeratorUserLookupError::ServerError, + RequireModeratorError::NotAuthorized => ModeratorUserLookupError::Unauthorized, + })?; + + let search = request.search.trim(); + + if search.is_empty() { + let response = ModeratorUserLookupSuccessResponse { + success: true, + maybe_user: None, + }; + let body = serde_json::to_string(&response) + .map_err(|_| ModeratorUserLookupError::ServerError)?; + return Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)); + } + + let maybe_result = if search.starts_with(UserToken::token_prefix()) || search.starts_with(LEGACY_USER_TOKEN_PREFIX) { + lookup_user_for_moderation_by_token(search, &server_state.mysql_pool).await + } else if search.contains('@') { + lookup_user_for_moderation_by_email(search, &server_state.mysql_pool).await + } else { + lookup_user_for_moderation_by_username(search, &server_state.mysql_pool).await + }; + + let maybe_user = maybe_result + .map_err(|err| { + warn!("moderator_user_lookup error: {:?}", err); + ModeratorUserLookupError::ServerError + })? + .map(ModeratorUserLookupUserDetails::from); + + let response = ModeratorUserLookupSuccessResponse { + success: true, + maybe_user, + }; + + let body = serde_json::to_string(&response) + .map_err(|_| ModeratorUserLookupError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} 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 42bf83f4df..6cef88d248 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 @@ -24,6 +24,7 @@ use crate::http_server::endpoints::moderation::jobs::get_w2l_inference_queue_cou 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::list_banned_users::list_banned_users_handler; +use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler; use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler; pub fn add_moderator_routes (app: App) -> App @@ -42,6 +43,10 @@ pub fn add_moderator_routes (app: App) -> App .route(web::post().to(edit_user_feature_flags_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/user_lookup") + .route(web::post().to(moderator_user_lookup_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) .service(web::scope("/moderation") .service(web::resource("/staff")