Compare commits

...
Author SHA1 Message Date
Brandon Thomas 0ddcd178c8 edits; list user feature flags 2026-04-24 03:42:25 -04:00
Brandon Thomas ae68604082 list flags handler 2026-04-24 03:37:46 -04:00
Brandon Thomas b15c27971b rename file 2026-04-24 03:34:49 -04:00
Brandon Thomas 6e6eb4ea16 update permission check 2026-04-24 03:30:56 -04:00
Brandon Thomas fdd9bafa1c new endpoint to show available flags 2026-04-24 03:27:55 -04:00
Brandon Thomas c59401e33a rename endpoint 2026-04-24 03:13:18 -04:00
Brandon Thomas 9979bad85b move endpoint 2026-04-24 03:09:55 -04:00
7 changed files with 277 additions and 52 deletions
@@ -36,6 +36,10 @@ pub enum StaffAuditAction {
/// Staff sent a manual alert via the pager system.
#[serde(rename = "send_alert")]
SendAlert,
/// Staff edited a user's feature flags.
#[serde(rename = "edit_user_feature_flags")]
EditUserFeatureFlags,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
@@ -51,6 +55,7 @@ impl StaffAuditAction {
Self::UnbanUser => "unban_user",
Self::AddWalletBankedBalance => "add_wallet_banked_balance",
Self::SendAlert => "send_alert",
Self::EditUserFeatureFlags => "edit_user_feature_flags",
}
}
@@ -62,6 +67,7 @@ impl StaffAuditAction {
"unban_user" => Ok(Self::UnbanUser),
"add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance),
"send_alert" => Ok(Self::SendAlert),
"edit_user_feature_flags" => Ok(Self::EditUserFeatureFlags),
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
}
}
@@ -74,6 +80,7 @@ impl StaffAuditAction {
Self::UnbanUser,
Self::AddWalletBankedBalance,
Self::SendAlert,
Self::EditUserFeatureFlags,
])
}
}
@@ -94,6 +101,7 @@ mod tests {
assert_serialization(StaffAuditAction::UnbanUser, "unban_user");
assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance");
assert_serialization(StaffAuditAction::SendAlert, "send_alert");
assert_serialization(StaffAuditAction::EditUserFeatureFlags, "edit_user_feature_flags");
}
#[test]
@@ -104,6 +112,7 @@ mod tests {
assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user");
assert_eq!(StaffAuditAction::AddWalletBankedBalance.to_str(), "add_wallet_banked_balance");
assert_eq!(StaffAuditAction::SendAlert.to_str(), "send_alert");
assert_eq!(StaffAuditAction::EditUserFeatureFlags.to_str(), "edit_user_feature_flags");
}
#[test]
@@ -114,12 +123,13 @@ mod tests {
assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser);
assert_eq!(StaffAuditAction::from_str("add_wallet_banked_balance").unwrap(), StaffAuditAction::AddWalletBankedBalance);
assert_eq!(StaffAuditAction::from_str("send_alert").unwrap(), StaffAuditAction::SendAlert);
assert_eq!(StaffAuditAction::from_str("edit_user_feature_flags").unwrap(), StaffAuditAction::EditUserFeatureFlags);
assert!(StaffAuditAction::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 6;
const EXPECTED_COUNT: usize = 7;
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
}
}
@@ -85,7 +85,9 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asse
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
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::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_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::*;
@@ -397,7 +399,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::write_engine_asset_media_file_handler,
crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::write_scene_file_media_file_handler,
crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::enqueue_gptsovits_model_download_handler,
crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler,
crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::moderator_edit_user_feature_flags_handler,
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::moderator_list_all_available_user_feature_flags_handler,
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::moderator_list_user_feature_flags_handler,
crate::http_server::endpoints::prompts::create_prompt_handler::create_prompt_handler,
crate::http_server::endpoints::prompts::get_prompt_handler::get_prompt_handler,
crate::http_server::endpoints::service::status_alert_handler::status_alert_handler,
@@ -1 +1,3 @@
pub mod edit_user_feature_flags_handler;
pub mod moderator_edit_user_feature_flags_handler;
pub mod moderator_list_all_available_user_feature_flags_handler;
pub mod moderator_list_user_feature_flags_handler;
@@ -1,6 +1,7 @@
use std::collections::BTreeSet;
use std::fmt;
use std::iter::FromIterator;
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::error::ResponseError;
@@ -9,20 +10,27 @@ use actix_web::web::{Data, Json, Path};
use actix_web::{HttpRequest, HttpResponse};
use log::warn;
use redis::{Client, Commands};
use sqlx::Acquire;
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 enums::by_table::users::user_feature_flag::UserFeatureFlag;
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::get::get_user_token_by_username::get_user_token_by_username;
use mysql_queries::queries::users::user::update::set_user_feature_flags::{set_user_feature_flags, SetUserFeatureFlagArgs};
use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token;
use tokens::tokens::users::UserToken;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
/// For the URL PathInfo
#[derive(Deserialize, ToSchema)]
@@ -94,7 +102,7 @@ impl fmt::Display for EditUserFeatureFlagsError {
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/user_feature_flags/{username_or_token}",
path = "/v1/moderation/user_feature_flags/user/{username_or_token}",
responses(
(status = 200, description = "Success", body = SimpleGenericJsonSuccess),
(status = 401, description = "Unauthorized", body = EditUserFeatureFlagsError),
@@ -106,66 +114,47 @@ impl fmt::Display for EditUserFeatureFlagsError {
("request" = EditUserFeatureFlagsRequest, description = "Payload for Request"),
)
)]
pub async fn edit_user_feature_flags_handler(
pub async fn moderator_edit_user_feature_flags_handler(
http_request: HttpRequest,
path: Path<EditUserFeatureFlagPathInfo>,
request: Json<EditUserFeatureFlagsRequest>,
server_state: Data<Arc<ServerState>>,
//redis_ttl_cache: Data<RedisTtlCache>,
redis_pool: Data<r2d2::Pool<Client>>,
) -> Result<HttpResponse, EditUserFeatureFlagsError> {
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);
EditUserFeatureFlagsError::ServerError
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => {
return Err(EditUserFeatureFlagsError::Unauthorized);
}
};
if !user_session.can_ban_users {
warn!("user is not allowed to add bans: {:?}", user_session.user_token.as_str());
return Err(EditUserFeatureFlagsError::Unauthorized);
}
let user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
EditUserFeatureFlagsError::Unauthorized
})?;
let username_or_token = path.username_or_token.trim();
let user_token;
if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
user_token = UserToken::new_from_str(username_or_token);
let user_token = if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
UserToken::new_from_str(username_or_token)
} else {
user_token = get_user_token_by_username(&username_or_token, &server_state.mysql_pool)
get_user_token_by_username(username_or_token, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Could not get user token by username: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?
.ok_or_else(|| {
EditUserFeatureFlagsError::ServerError
})?;
}
.ok_or(EditUserFeatureFlagsError::ServerError)?
};
let user_profile = get_user_profile_by_token(&user_token, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Could not get user session by token: {:?}", e);
warn!("Could not get user profile by token: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?
.ok_or_else(|| {
EditUserFeatureFlagsError::ServerError
})?;
.ok_or(EditUserFeatureFlagsError::ServerError)?;
let mut user_feature_flags =
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
match &request.action {
EditUserFeatureFlagsOption::AddFlags { flags } => {
@@ -189,6 +178,7 @@ pub async fn edit_user_feature_flags_handler(
let ip_address = get_request_ip(&http_request);
// Update the user's feature flags.
set_user_feature_flags(SetUserFeatureFlagArgs {
subject_user_token: &user_profile.user_token,
maybe_feature_flags: user_feature_flags.maybe_serialize_string().as_deref(),
@@ -201,16 +191,45 @@ pub async fn edit_user_feature_flags_handler(
EditUserFeatureFlagsError::ServerError
})?;
//if let Ok(mut redis) = redis_ttl_cache.get_connection() {
// // TODO(bt,2024-04-20): This should be coordinated with other code.
// let cache_key = format!("cache:userProfile:{}", user_profile.username);
// let _r = redis.delete_from_cache(&cache_key);
//}
// Insert staff audit log.
let mut mysql_connection = server_state.mysql_pool.acquire()
.await
.map_err(|e| {
warn!("Could not acquire MySQL connection for audit log: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?;
let mut transaction = mysql_connection.begin()
.await
.map_err(|e| {
warn!("Could not start transaction for audit log: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?;
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::EditUserFeatureFlags,
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);
EditUserFeatureFlagsError::ServerError
})?;
transaction.commit()
.await
.map_err(|e| {
warn!("Could not commit audit log transaction: {:?}", e);
EditUserFeatureFlagsError::ServerError
})?;
// Invalidate Redis cache for the user profile.
if let Ok(mut redis) = redis_pool.get() {
// TODO(bt,2024-04-20): This should be coordinated with other code.
let cache_key = format!("cache:userProfile:{}", user_profile.username);
let _r : Result<Option<String>, _> = redis.del(&cache_key);
let _r: Result<Option<String>, _> = redis.del(&cache_key);
}
Ok(simple_json_success())
@@ -0,0 +1,74 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use utoipa::ToSchema;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
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;
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUserFeatureFlagsResponse {
pub success: bool,
pub feature_flags: Vec<FeatureFlagDescriptor>,
}
#[derive(Serialize, ToSchema)]
pub struct FeatureFlagDescriptor {
/// Key the flag is identified by
pub key: String,
/// Full name of the flag
pub full_name: String,
/// Description of the flag
pub description: String,
}
// ── Handler ──
/// List all available user feature flags.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_feature_flags/list",
responses(
(status = 200, description = "Success", body = ModeratorListUserFeatureFlagsResponse),
(status = 401, description = "Unauthorized"),
),
)]
pub async fn moderator_list_all_available_user_feature_flags_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, AdvancedCommonWebError> {
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let feature_flags: Vec<FeatureFlagDescriptor> = UserFeatureFlag::all_variants()
.into_iter()
.map(|flag| FeatureFlagDescriptor {
key: flag.to_str().to_string(),
full_name: flag.name().to_string(),
description: flag.description().to_string(),
})
.collect();
Ok(Json(ModeratorListUserFeatureFlagsResponse {
success: true,
feature_flags,
}))
}
@@ -0,0 +1,109 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use utoipa::ToSchema;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use mysql_queries::queries::users::user::get::get_user_token_by_username::get_user_token_by_username;
use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
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 ListUserFeatureFlagsPathInfo {
username_or_token: String,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUserFeatureFlagsResponse {
pub success: bool,
pub user_token: String,
pub username: String,
pub display_name: String,
pub feature_flags: Vec<String>,
}
// ── Handler ──
/// List the feature flags enabled for a specific user.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_feature_flags/user/{username_or_token}",
responses(
(status = 200, description = "Success", body = ModeratorListUserFeatureFlagsResponse),
(status = 401, description = "Unauthorized"),
(status = 400, description = "Bad input"),
),
params(
("username_or_token" = String, Path, description = "Username or user token"),
)
)]
pub async fn moderator_list_user_feature_flags_handler(
http_request: HttpRequest,
path: Path<ListUserFeatureFlagsPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, AdvancedCommonWebError> {
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let username_or_token = path.username_or_token.trim();
let user_token = if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
UserToken::new_from_str(username_or_token)
} else {
get_user_token_by_username(username_or_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Could not get user token by username: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
AdvancedCommonWebError::NotFound
})?
};
let user_profile = get_user_profile_by_token(&user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Could not get user profile by token: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
AdvancedCommonWebError::NotFound
})?;
let user_feature_flags =
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
let flags: Vec<String> = user_feature_flags.clone_flags()
.iter()
.map(|flag| flag.to_str().to_string())
.collect();
Ok(Json(ModeratorListUserFeatureFlagsResponse {
success: true,
user_token: user_profile.user_token.as_str().to_string(),
username: user_profile.username,
display_name: user_profile.display_name,
feature_flags: flags,
}))
}
@@ -36,7 +36,9 @@ use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_st
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::moderator_edit_user_feature_flags_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::moderator_list_all_available_user_feature_flags_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::moderator_list_user_feature_flags_handler;
use 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;
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler;
use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler;
@@ -59,8 +61,13 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/user_feature_flags/{username_or_token}")
.route(web::post().to(edit_user_feature_flags_handler))
.service(web::resource("/user_feature_flags/list")
.route(web::get().to(moderator_list_all_available_user_feature_flags_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/user_feature_flags/user/{username_or_token}")
.route(web::get().to(moderator_list_user_feature_flags_handler))
.route(web::post().to(moderator_edit_user_feature_flags_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::scope("/users")