diff --git a/crates/lib/mysql_queries/src/queries/generic_inference/web/get_inference_job_status.rs b/crates/lib/mysql_queries/src/queries/generic_inference/web/get_inference_job_status.rs index 771d992aac..af3767d3ee 100644 --- a/crates/lib/mysql_queries/src/queries/generic_inference/web/get_inference_job_status.rs +++ b/crates/lib/mysql_queries/src/queries/generic_inference/web/get_inference_job_status.rs @@ -15,7 +15,8 @@ use tokens::tokens::users::UserToken; use crate::helpers::boolean_converters::i8_to_bool; -#[derive(Debug, Default)] +// NB: Serialization is for internal endpoints, not for returning to end-users. +#[derive(Debug, Default, Serialize)] pub struct GenericInferenceJobStatus { pub job_token: InferenceJobToken, @@ -39,9 +40,10 @@ pub struct GenericInferenceJobStatus { pub updated_at: DateTime, } +/// NB: Serialize is for internal moderator-only endpoints /// Details about the user's original inference request /// (We may want to present it in the "pending" UI.) -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] pub struct RequestDetails { pub inference_category: InferenceCategory, pub maybe_model_type: Option, // TODO: Strongly type @@ -52,8 +54,9 @@ pub struct RequestDetails { pub maybe_raw_inference_text: Option, } +/// NB: Serialize is for internal moderator-only endpoints /// Details about the generated result -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] pub struct ResultDetails { pub entity_type: String, pub entity_token: String, @@ -71,8 +74,9 @@ pub struct ResultDetails { } /// NB: DO NOT EXPOSE TO FRONTEND. +/// NB: Serialize is for internal moderator-only endpoints /// This is used to gate access to job termination -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize)] pub struct UserDetails { pub maybe_creator_user_token: Option, pub maybe_creator_anonymous_visitor_token: Option, diff --git a/crates/lib/mysql_queries/src/queries/model_weights/get/get_weight.rs b/crates/lib/mysql_queries/src/queries/model_weights/get/get_weight.rs index 6a5f15a83e..c95e4003a6 100644 --- a/crates/lib/mysql_queries/src/queries/model_weights/get/get_weight.rs +++ b/crates/lib/mysql_queries/src/queries/model_weights/get/get_weight.rs @@ -14,6 +14,8 @@ use tokens::tokens::{model_weights::ModelWeightToken, users::UserToken}; // 'weights_type: enums::by_table::model_weights::weights_types::WeightsType' use this to map // Retrieved Model Weight can be constrained to the fields that are needed +// NB: Serialize is only for internal moderator endpoints +#[derive(Serialize)] pub struct RetrievedModelWeight { pub token: ModelWeightToken, pub title: String, diff --git a/crates/lib/mysql_queries/src/queries/users/user_profiles/get_user_profile_by_token.rs b/crates/lib/mysql_queries/src/queries/users/user_profiles/get_user_profile_by_token.rs new file mode 100644 index 0000000000..7d9599508a --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/users/user_profiles/get_user_profile_by_token.rs @@ -0,0 +1,177 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use log::warn; +use sqlx::{MySql, MySqlPool}; +use sqlx::pool::PoolConnection; + +use enums::common::visibility::Visibility; +use errors::AnyhowResult; +use tokens::tokens::users::UserToken; + +use crate::helpers::boolean_converters::i8_to_bool; + +// NB: Serialize for internal moderator endpoints +#[derive(Serialize, Clone, Debug)] +pub struct UserProfileResult { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, + pub profile_markdown: String, + pub profile_rendered_html: String, + pub user_role_slug: String, + pub disable_gravatar: bool, + + // NB: Legacy top-level moderator field + #[deprecated = "use moderator fields instead"] + pub is_banned: bool, + + // Social + pub discord_username: Option, + pub twitch_username: Option, + pub twitter_username: Option, + pub patreon_username: Option, + pub github_username: Option, + pub cashapp_username: Option, + pub website_url: Option, + + // Preferences; NB: included for get_profile_handler legacy reasons + pub preferred_tts_result_visibility: Visibility, + pub preferred_w2l_result_visibility: Visibility, + + pub created_at: DateTime, + + // NB: Moderator fields must be cleared by HTTP handlers for non-mods + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_moderator_fields: Option, +} + +// TODO: Make non-`Serialize` and make the HTTP endpoints do the work +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UserProfileModeratorFields { + pub is_banned: bool, + pub maybe_mod_comments: Option, + pub maybe_mod_user_token: Option, +} + +struct RawUserProfileRecord { + user_token: UserToken, + username: String, + email_gravatar_hash: String, + display_name: String, + profile_markdown: String, + profile_rendered_html: String, + user_role_slug: String, + disable_gravatar: i8, + discord_username: Option, + twitch_username: Option, + twitter_username: Option, + patreon_username: Option, + github_username: Option, + cashapp_username: Option, + website_url: Option, + created_at: DateTime, + + // Preferences; NB: included for get_profile_handler legacy reasons + preferred_tts_result_visibility: Visibility, + preferred_w2l_result_visibility: Visibility, + + // Mod fields + is_banned: i8, + maybe_mod_comments: Option, + maybe_mod_user_token: Option, +} + +//#[deprecated = "Use the PoolConnection method"] +pub async fn get_user_profile_by_token( + token: &UserToken, + mysql_pool: &MySqlPool +) -> AnyhowResult> { + let mut connection = mysql_pool.acquire().await?; + get_user_profile_by_token_from_connection(token, &mut connection).await +} + +pub async fn get_user_profile_by_token_from_connection<'a>( + token: &UserToken, + connection: &'a mut PoolConnection +) -> AnyhowResult> { + let maybe_profile_record = sqlx::query_as!( + RawUserProfileRecord, + r#" +SELECT + users.token as `user_token: tokens::tokens::users::UserToken`, + username, + display_name, + email_gravatar_hash, + profile_markdown, + profile_rendered_html, + user_role_slug, + disable_gravatar, + preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`, + preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`, + discord_username, + twitch_username, + twitter_username, + patreon_username, + github_username, + cashapp_username, + website_url, + is_banned, + maybe_mod_comments, + maybe_mod_user_token, + created_at +FROM users +WHERE + users.token = ? + AND users.user_deleted_at IS NULL + AND users.mod_deleted_at IS NULL + "#, + token, + ) + .fetch_one(&mut **connection) + .await; + + let profile_record : RawUserProfileRecord = match maybe_profile_record { + Ok(profile_record) => profile_record, + Err(err) => { + return match err { + sqlx::Error::RowNotFound => { + Ok(None) + }, + _ => { + warn!("User profile query error: {:?}", err); + Err(anyhow!("query error")) + } + } + } + }; + + let profile_for_response = UserProfileResult { + user_token: profile_record.user_token, + username: profile_record.username, + display_name: profile_record.display_name, + email_gravatar_hash: profile_record.email_gravatar_hash, + profile_markdown: profile_record.profile_markdown, + profile_rendered_html: profile_record.profile_rendered_html, + user_role_slug: profile_record.user_role_slug, + is_banned: i8_to_bool(profile_record.is_banned), + disable_gravatar: i8_to_bool(profile_record.disable_gravatar), + discord_username: profile_record.discord_username, + twitch_username: profile_record.twitch_username, + twitter_username: profile_record.twitter_username, + patreon_username: profile_record.patreon_username, + github_username: profile_record.github_username, + cashapp_username: profile_record.cashapp_username, + website_url: profile_record.website_url, + preferred_tts_result_visibility: profile_record.preferred_tts_result_visibility, + preferred_w2l_result_visibility: profile_record.preferred_w2l_result_visibility, + created_at: profile_record.created_at, + maybe_moderator_fields: Some(UserProfileModeratorFields { + is_banned: i8_to_bool(profile_record.is_banned), + maybe_mod_comments: profile_record.maybe_mod_comments, + maybe_mod_user_token: profile_record.maybe_mod_user_token, + }) + }; + + Ok(Some(profile_for_response)) +} diff --git a/crates/lib/mysql_queries/src/queries/users/user_profiles/mod.rs b/crates/lib/mysql_queries/src/queries/users/user_profiles/mod.rs index 0167208cd4..c7c64baa01 100644 --- a/crates/lib/mysql_queries/src/queries/users/user_profiles/mod.rs +++ b/crates/lib/mysql_queries/src/queries/users/user_profiles/mod.rs @@ -1,3 +1,4 @@ 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 diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/mod.rs new file mode 100644 index 0000000000..88ef3d4bba --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/mod.rs @@ -0,0 +1 @@ +pub mod moderator_token_info_handler; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/moderator_token_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/moderator_token_info_handler.rs new file mode 100644 index 0000000000..af77fe805a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/info/moderator_token_info_handler.rs @@ -0,0 +1,183 @@ +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Path; +use log::{log, warn}; +use serde::Serialize; +use sqlx::MySqlPool; +use utoipa::ToSchema; +use errors::AnyhowResult; +use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status; +use mysql_queries::queries::media_files::get_media_file::get_media_file; +use mysql_queries::queries::model_weights::get::get_weight::get_weight_by_token; +use mysql_queries::queries::model_weights::model_weight_info_lite::get_model_weight_info_lite::get_model_weight_info_lite; +use mysql_queries::queries::prompts::get_prompt::get_prompt; + +use mysql_queries::queries::tts::tts_inference_jobs::get_pending_tts_inference_job_detailed_stats::{get_pending_tts_inference_job_detailed_stats, PendingCountResult}; +use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token; +use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error; +use crate::server_state::ServerState; + +/// For the URL PathInfo +#[derive(Deserialize, ToSchema)] +pub struct ModeratorTokenInfoPath { + token: String, +} +#[derive(Serialize, ToSchema)] +pub struct ModeratorTokenInfoResponse { + pub success: bool, + + /// Json-encoded payload + pub maybe_payload: Option, +} + +#[derive(Debug, Serialize)] +pub enum ModeratorTokenInfoError { + ServerError, + Unauthorized, +} + +impl ResponseError for ModeratorTokenInfoError { + fn status_code(&self) -> StatusCode { + match *self { + ModeratorTokenInfoError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + ModeratorTokenInfoError::Unauthorized => StatusCode::UNAUTHORIZED, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using DeriveMore since Clion doesn't understand it. +impl fmt::Display for ModeratorTokenInfoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +#[utoipa::path( + get, + path = "/v1/moderation/token_info/{token}", + responses( + (status = 200, description = "Found", body = ModeratorTokenInfoResponse), + (status = 401, description = "Not authorized", body = ModeratorTokenInfoError), + (status = 500, description = "Server error", body = ModeratorTokenInfoError), + ), + params( + ("path" = ModeratorTokenInfoPath, description = "Path for Request") + ) +)] +pub async fn moderator_get_token_info_handler( + path: Path, + http_request: HttpRequest, + 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); + ModeratorTokenInfoError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(ModeratorTokenInfoError::Unauthorized); + } + }; + + // TODO: Not a good fit for this permission. + if !user_session.can_ban_users { + warn!("user is not allowed to view bans: {}", user_session.user_token); + return Err(ModeratorTokenInfoError::Unauthorized); + } + + let token = path.token.trim(); + let maybe_result = get_entity_from_token(&server_state.mysql_pool, &path.token) + .await + .map_err(|err| { + warn!("get tts pending count error: {:?}", err); + ModeratorTokenInfoError::ServerError + })?; + + let response = ModeratorTokenInfoResponse { + success: true, + maybe_payload: maybe_result, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| ModeratorTokenInfoError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} + +const LEGACY_USER_TOKEN_PREFIX : &str = "U:"; + +async fn get_entity_from_token(mysql_pool: &MySqlPool, token: &str) -> AnyhowResult> { + + if token.starts_with(UserToken::token_prefix()) || token.starts_with(LEGACY_USER_TOKEN_PREFIX) { + let typed_token = UserToken::new_from_str(token); + let maybe_result = get_user_profile_by_token(&typed_token, mysql_pool) + .await?; + return maybe_to_string(maybe_result); + } + + if token.starts_with(MediaFileToken::token_prefix()) { + let typed_token = MediaFileToken::new_from_str(token); + let maybe_result = get_media_file(&typed_token, true, mysql_pool) + .await?; + return maybe_to_string(maybe_result); + } + + if token.starts_with(ModelWeightToken::token_prefix()) { + let typed_token = ModelWeightToken::new_from_str(token); + let maybe_result = get_weight_by_token(&typed_token, true, mysql_pool) + .await?; + return maybe_to_string(maybe_result); + } + + if token.starts_with(InferenceJobToken::token_prefix()) { + let typed_token = InferenceJobToken::new_from_str(token); + let maybe_result = get_inference_job_status(&typed_token, mysql_pool) + .await?; + return maybe_to_string(maybe_result); + } + + if token.starts_with(PromptToken::token_prefix()) { + let typed_token = PromptToken::new_from_str(token); + let maybe_result = get_prompt(&typed_token, mysql_pool) + .await?; + return maybe_to_string(maybe_result); + } + + // If nothing else works, try username lookup + let maybe_result = get_user_profile_by_username(token, mysql_pool) + .await?; + + maybe_to_string(maybe_result) +} + +fn maybe_to_string(maybe_data: Option) -> AnyhowResult> { + let maybe_stringified = maybe_data.map(|data| serde_json::to_string(&data)) + .transpose()?; + Ok(maybe_stringified) +} \ No newline at end of file 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 95f5febebb..2108b21546 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 approval; pub mod categories; +pub mod info; pub mod ip_bans; pub mod jobs; pub mod stats; diff --git a/crates/service/web/storyteller_web/src/routes/moderation_routes.rs b/crates/service/web/storyteller_web/src/routes/moderation_routes.rs index 022974df47..2cc81962a5 100644 --- a/crates/service/web/storyteller_web/src/routes/moderation_routes.rs +++ b/crates/service/web/storyteller_web/src/routes/moderation_routes.rs @@ -8,6 +8,7 @@ use crate::http_server::endpoints::moderation::approval::pending_w2l_templates:: use crate::http_server::endpoints::moderation::categories::delete_category::delete_category_handler; use crate::http_server::endpoints::moderation::categories::edit_category::edit_category_handler; use crate::http_server::endpoints::moderation::categories::list_tts_categories_for_moderation::list_tts_categories_for_moderation_handler; +use crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler; use crate::http_server::endpoints::moderation::ip_bans::add_ip_ban::add_ip_ban_handler; use crate::http_server::endpoints::moderation::ip_bans::delete_ip_ban::delete_ip_ban_handler; use crate::http_server::endpoints::moderation::ip_bans::get_ip_ban::get_ip_ban_handler; @@ -37,10 +38,13 @@ pub fn add_moderator_routes (app: App) -> App { app.service( web::scope("/moderation") - .service( - web::resource("/staff") - .route(web::get().to(list_staff_handler)) - .route(web::head().to(|| HttpResponse::Ok())) + .service(web::resource("/staff") + .route(web::get().to(list_staff_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/token_info/{token}") + .route(web::get().to(moderator_get_token_info_handler)) + .route(web::head().to(|| HttpResponse::Ok())) ) .service( web::scope("/ip_bans")