Compare commits

...
Author SHA1 Message Date
Brandon Thomas 38cbd724d4 codegen + fixes 2026-06-03 10:36:58 -04:00
Brandon Thomas f669e0071e list email address changes 2026-06-03 01:08:54 -04:00
Brandon Thomas a8af0e8141 mod endpoint to update email 2026-06-03 00:51:30 -04:00
Brandon Thomas a78ac8c391 cleanup 2026-06-03 00:38:02 -04:00
Brandon Thomas 3a62afb53c usernames 2026-06-03 00:33:52 -04:00
Brandon Thomas 998a7b60b1 queries for new table 2026-06-03 00:30:02 -04:00
Brandon Thomas aeebad3a40 enum variant 2026-06-03 00:21:17 -04:00
Brandon Thomas bb53238c56 new table to track email changes 2026-06-03 00:10:13 -04:00
14 changed files with 762 additions and 1 deletions
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_email_changes;
@@ -0,0 +1,35 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_email_changes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The user whose email was changed.
user_token VARCHAR(32) NOT NULL,
-- The email address before the change.
old_email VARCHAR(255) NOT NULL,
-- The email address after the change.
new_email VARCHAR(255) NOT NULL,
-- The IP address the change was made from.
-- Wide enough for IPv4/6.
ip_address VARCHAR(40) NOT NULL,
-- The user that performed the change, if known. NULL when the change was
-- performed by the user themselves, by an automated process, or in any
-- other context where there is no acting user.
maybe_changed_by_user_token VARCHAR(32) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_user_token (user_token),
KEY index_old_email (old_email),
KEY index_new_email (new_email),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,38 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_email_changes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The user whose email was changed.
user_token VARCHAR(32) NOT NULL,
-- The email address before the change.
old_email VARCHAR(255) NOT NULL,
-- The email address after the change.
new_email VARCHAR(255) NOT NULL,
-- The IP address the change was made from.
-- Wide enough for IPv4/6.
ip_address VARCHAR(40) NOT NULL,
-- The user that performed the change, if known. NULL when the change was
-- performed by the user themselves, by an automated process, or in any
-- other context where there is no acting user.
maybe_changed_by_user_token VARCHAR(32) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_user_token (user_token),
KEY index_old_email (old_email),
KEY index_new_email (new_email),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,6 +1,7 @@
pub mod user;
pub mod user_badges;
pub mod user_bookmarks;
pub mod user_email_changes;
pub mod user_password_resets;
pub mod user_profiles;
pub mod user_ratings;
@@ -0,0 +1,59 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
pub struct InsertUserEmailChangeArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The user whose email was changed.
pub user_token: &'e UserToken,
/// The email address before the change.
pub old_email: &'e str,
/// The email address after the change.
pub new_email: &'e str,
/// The IP address the change was made from.
pub ip_address: &'e str,
/// The user that performed the change, if known. `None` when the change
/// was performed by the user themselves, by an automated process, or in
/// any other context where there is no acting user.
pub maybe_changed_by_user_token: Option<&'e UserToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a single `user_email_changes` row recording an email-address change.
pub async fn insert_user_email_change<'e, 'c: 'e, E>(
args: InsertUserEmailChangeArgs<'e, 'c, E>,
) -> Result<(), sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
sqlx::query!(
r#"
INSERT INTO user_email_changes
SET
user_token = ?,
old_email = ?,
new_email = ?,
ip_address = ?,
maybe_changed_by_user_token = ?
"#,
args.user_token.as_str(),
args.old_email,
args.new_email,
args.ip_address,
args.maybe_changed_by_user_token.map(|t| t.as_str()),
)
.execute(args.mysql_executor)
.await?;
Ok(())
}
@@ -0,0 +1,208 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
pub struct ListUserEmailChangesForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The user whose email-change history is being listed.
pub user_token: &'e UserToken,
/// When `Some`, only return rows with `id < maybe_cursor_id`. Used to
/// page backward through the descending-by-id list.
pub maybe_cursor_id: Option<u64>,
/// Maximum number of rows to return.
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
#[derive(Debug)]
pub struct UserEmailChangeRow {
pub id: u64,
pub old_email: String,
pub new_email: String,
pub ip_address: String,
pub created_at: DateTime<Utc>,
/// The user whose email was changed (the subject of the audit row).
pub user: UserDisplay,
/// The user who performed the change. `None` for self-service changes
/// (where `user_email_changes.maybe_changed_by_user_token` is `NULL`) or
/// when the referenced user row has been hard-deleted.
pub maybe_changed_by_user: Option<UserDisplay>,
}
/// Denormalized display fields for a `users` row, joined into a query result.
#[derive(Debug)]
pub struct UserDisplay {
pub token: UserToken,
pub username: String,
pub display_name: String,
pub gravatar_hash: String,
}
#[derive(Debug)]
struct RawUserEmailChangeRow {
id: u64,
old_email: String,
new_email: String,
ip_address: String,
created_at: DateTime<Utc>,
user_token: UserToken,
user_username: String,
user_display_name: String,
user_gravatar_hash: String,
maybe_changed_by_user_token: Option<UserToken>,
maybe_changed_by_user_username: Option<String>,
maybe_changed_by_user_display_name: Option<String>,
maybe_changed_by_user_gravatar_hash: Option<String>,
}
/// Return `user_email_changes` rows for the given user, newest first,
/// capped at `limit`. When `maybe_cursor_id` is set, only rows with `id`
/// strictly less than the cursor are returned — pass the last id from the
/// previous page to walk backwards through history.
///
/// Joins `users` twice to denormalize the token, username, display name,
/// and gravatar hash for both the subject (`user_token`) and the acting
/// user (`maybe_changed_by_user_token`). The actor join is a LEFT JOIN so
/// rows where there is no acting user (e.g. self-service changes) still
/// come back.
pub async fn list_user_email_changes_for_user<'e, 'c: 'e, E>(
args: ListUserEmailChangesForUserArgs<'e, 'c, E>,
) -> Result<Vec<UserEmailChangeRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit as i64;
// Two query branches because sqlx::query_as! can't accept conditional
// WHERE clauses at the macro level. `args.mysql_executor` is moved into
// exactly one arm at runtime — Rust permits this since the arms are
// mutually exclusive.
let rows = match args.maybe_cursor_id {
Some(cursor_id) => {
sqlx::query_as!(
RawUserEmailChangeRow,
r#"
SELECT
uec.id as `id: u64`,
uec.old_email,
uec.new_email,
uec.ip_address,
uec.created_at,
u_subject.token as `user_token: tokens::tokens::users::UserToken`,
u_subject.username as user_username,
u_subject.display_name as user_display_name,
u_subject.email_gravatar_hash as user_gravatar_hash,
u_changer.token as `maybe_changed_by_user_token?: tokens::tokens::users::UserToken`,
u_changer.username as `maybe_changed_by_user_username?`,
u_changer.display_name as `maybe_changed_by_user_display_name?`,
u_changer.email_gravatar_hash as `maybe_changed_by_user_gravatar_hash?`
FROM user_email_changes AS uec
JOIN users AS u_subject
ON uec.user_token = u_subject.token
LEFT JOIN users AS u_changer
ON uec.maybe_changed_by_user_token = u_changer.token
WHERE uec.user_token = ?
AND uec.id < ?
ORDER BY uec.id DESC
LIMIT ?
"#,
args.user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.mysql_executor)
.await?
}
None => {
sqlx::query_as!(
RawUserEmailChangeRow,
r#"
SELECT
uec.id as `id: u64`,
uec.old_email,
uec.new_email,
uec.ip_address,
uec.created_at,
u_subject.token as `user_token: tokens::tokens::users::UserToken`,
u_subject.username as user_username,
u_subject.display_name as user_display_name,
u_subject.email_gravatar_hash as user_gravatar_hash,
u_changer.token as `maybe_changed_by_user_token?: tokens::tokens::users::UserToken`,
u_changer.username as `maybe_changed_by_user_username?`,
u_changer.display_name as `maybe_changed_by_user_display_name?`,
u_changer.email_gravatar_hash as `maybe_changed_by_user_gravatar_hash?`
FROM user_email_changes AS uec
JOIN users AS u_subject
ON uec.user_token = u_subject.token
LEFT JOIN users AS u_changer
ON uec.maybe_changed_by_user_token = u_changer.token
WHERE uec.user_token = ?
ORDER BY uec.id DESC
LIMIT ?
"#,
args.user_token.as_str(),
limit,
)
.fetch_all(args.mysql_executor)
.await?
}
};
let results = rows.into_iter().map(|row| {
let user = UserDisplay {
token: row.user_token,
username: row.user_username,
display_name: row.user_display_name,
gravatar_hash: row.user_gravatar_hash,
};
// The four actor columns come from the same LEFT JOIN row, so they are
// either all `Some` (matched user row) or all `None` (no actor token or
// the user row was hard-deleted) together.
let maybe_changed_by_user = match (
row.maybe_changed_by_user_token,
row.maybe_changed_by_user_username,
row.maybe_changed_by_user_display_name,
row.maybe_changed_by_user_gravatar_hash,
) {
(Some(token), Some(username), Some(display_name), Some(gravatar_hash)) => Some(UserDisplay {
token,
username,
display_name,
gravatar_hash,
}),
_ => None,
};
UserEmailChangeRow {
id: row.id,
old_email: row.old_email,
new_email: row.new_email,
ip_address: row.ip_address,
created_at: row.created_at,
user,
maybe_changed_by_user,
}
}).collect();
Ok(results)
}
@@ -0,0 +1,2 @@
pub mod insert_user_email_change;
pub mod list_user_email_changes_for_user;
@@ -40,6 +40,10 @@ pub enum StaffAuditAction {
/// Staff edited a user's feature flags.
#[serde(rename = "edit_user_feature_flags")]
EditUserFeatureFlags,
/// Staff changed a user's email address.
#[serde(rename = "change_user_email")]
ChangeUserEmail,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
@@ -56,6 +60,7 @@ impl StaffAuditAction {
Self::AddWalletBankedBalance => "add_wallet_banked_balance",
Self::SendAlert => "send_alert",
Self::EditUserFeatureFlags => "edit_user_feature_flags",
Self::ChangeUserEmail => "change_user_email",
}
}
@@ -68,6 +73,7 @@ impl StaffAuditAction {
"add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance),
"send_alert" => Ok(Self::SendAlert),
"edit_user_feature_flags" => Ok(Self::EditUserFeatureFlags),
"change_user_email" => Ok(Self::ChangeUserEmail),
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
}
}
@@ -81,6 +87,7 @@ impl StaffAuditAction {
Self::AddWalletBankedBalance,
Self::SendAlert,
Self::EditUserFeatureFlags,
Self::ChangeUserEmail,
])
}
}
@@ -102,6 +109,7 @@ mod tests {
assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance");
assert_serialization(StaffAuditAction::SendAlert, "send_alert");
assert_serialization(StaffAuditAction::EditUserFeatureFlags, "edit_user_feature_flags");
assert_serialization(StaffAuditAction::ChangeUserEmail, "change_user_email");
}
#[test]
@@ -113,6 +121,7 @@ mod tests {
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");
assert_eq!(StaffAuditAction::ChangeUserEmail.to_str(), "change_user_email");
}
#[test]
@@ -124,12 +133,13 @@ mod tests {
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_eq!(StaffAuditAction::from_str("change_user_email").unwrap(), StaffAuditAction::ChangeUserEmail);
assert!(StaffAuditAction::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 7;
const EXPECTED_COUNT: usize = 8;
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
}
}
@@ -92,6 +92,8 @@ use crate::http_server::endpoints::moderation::user_feature_flags::moderator_lis
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_emails::moderator_change_user_email::*;
use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::*;
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*;
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
@@ -533,6 +535,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::user_referrals::moderator_list_global_user_referrals_handler::moderator_list_global_user_referrals_handler,
crate::http_server::endpoints::moderation::user_referrals::moderator_list_user_referrals_for_user_handler::moderator_list_user_referrals_for_user_handler,
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email::moderator_change_user_email_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::moderator_list_email_address_changes_for_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,
@@ -956,6 +960,11 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ModerationBanUserSuccessResponse,
ModerationImpersonateRequest,
ModerationImpersonateSuccessResponse,
ModeratorChangeUserEmailRequest,
ModeratorChangeUserEmailSuccessResponse,
ModeratorListUserEmailChangesSuccessResponse,
ModeratorUserEmailChangeItem,
ModeratorUserEmailChangeUserSummary,
ModernInferenceQueueStats,
PinnedMediaFile,
PinnedModelWeightForList,
@@ -1193,6 +1202,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListUserReferralsForUserQueryParams,
ListUserReferralsForUserPathInfo,
ListUserReferralsForUserSuccessResponse,
// User Emails (Moderation)
ModeratorListUserEmailChangesQueryParams,
))
)]
pub struct ApiDoc;
@@ -6,6 +6,7 @@ pub mod jobs;
pub mod staff_audit_logs;
pub mod user;
pub mod user_bans;
pub mod user_emails;
pub mod user_feature_flags;
pub mod user_referrals;
pub mod user_sessions;
@@ -0,0 +1,2 @@
pub mod moderator_change_user_email;
pub mod moderator_list_email_address_changes_for_user;
@@ -0,0 +1,179 @@
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::get::lookup_user_for_moderation::lookup_user_for_moderation_by_token;
use mysql_queries::queries::users::user::update::update_email::{
update_email, UpdateEmailArgs, UpdateEmailError,
};
use mysql_queries::queries::users::user_email_changes::insert_user_email_change::{
insert_user_email_change, InsertUserEmailChangeArgs,
};
use mysql_queries::utils::transactor::Transactor;
use users::email::email_to_gravatar_hash::email_to_gravatar_hash;
use users::email::validate_email_address_format::validate_email_address_format;
use crate::http_server::common_responses::common_web_error::CommonWebError;
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 ModeratorChangeUserEmailRequest {
/// The user whose email should be changed.
pub user_token: String,
/// The new email address. Will be trimmed and lowercased before use.
pub new_email: String,
}
// --- Response ---
#[derive(Serialize, ToSchema)]
pub struct ModeratorChangeUserEmailSuccessResponse {
pub success: bool,
}
// --- Handler ---
/// Change a user's email address as a moderator. Records the change in
/// `user_email_changes` and writes a `staff_audit_logs` row.
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/user_emails/change",
request_body = ModeratorChangeUserEmailRequest,
responses(
(status = 200, description = "Email changed", body = ModeratorChangeUserEmailSuccessResponse),
(status = 400, description = "Bad email format or duplicate email", body = CommonWebError),
(status = 401, description = "Unauthorized", body = CommonWebError),
(status = 404, description = "Target user not found", body = CommonWebError),
(status = 500, description = "Server error", body = CommonWebError),
),
)]
pub async fn moderator_change_user_email_handler(
http_request: HttpRequest,
request: Json<ModeratorChangeUserEmailRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorChangeUserEmailSuccessResponse>, CommonWebError> {
// 1. Require moderator.
let user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
// 2. Normalize + validate the new email.
let new_email = request.new_email.trim().to_lowercase();
if let Err(reason) = validate_email_address_format(&new_email) {
return Err(CommonWebError::BadInputWithSimpleMessage(format!("bad email: {}", reason)));
}
// 3. Look up the target user. Not-found → 404; everything else → 500.
let target_user_token_input = request.user_token.trim();
let target = lookup_user_for_moderation_by_token(target_user_token_input, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Target user lookup failed: {:?}", err);
CommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
warn!("Target user not found: {:?}", target_user_token_input);
CommonWebError::NotFound
})?;
let target_user_token = target.user_token;
let old_email = target.email_address;
let new_gravatar_hash = email_to_gravatar_hash(&new_email);
let actor_ip = get_request_ip(&http_request);
info!(
"Moderator {} changing email for user {}.",
user_session.user_token.as_str(), target_user_token.as_str(),
);
// 4. Open transaction: update email, audit row, audit log.
let mut transaction = server_state.mysql_pool.begin().await
.map_err(|err| {
warn!("Failed to begin transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
// 4a. Update the user's email address. Duplicate-email errors surface as 400.
match update_email(UpdateEmailArgs {
token: &target_user_token,
email_address: &new_email,
email_gravatar_hash: &new_gravatar_hash,
ip_address: &actor_ip,
transactor: Transactor::for_transaction(&mut transaction),
}).await {
Ok(()) => {}
Err(UpdateEmailError::EmailIsTaken) => {
return Err(CommonWebError::BadInputWithSimpleMessage(
"email address is already in use".to_string(),
));
}
Err(UpdateEmailError::DatabaseError { source }) => {
warn!("Error updating user email: {:?}", source);
return Err(CommonWebError::from_error(source));
}
}
// 4b. Record the change in user_email_changes.
insert_user_email_change(InsertUserEmailChangeArgs {
user_token: &target_user_token,
old_email: &old_email,
new_email: &new_email,
ip_address: &actor_ip,
maybe_changed_by_user_token: Some(&user_session.user_token),
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert user_email_changes row: {:?}", err);
CommonWebError::from_error(err)
})?;
// 4c. Insert staff audit log.
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::ChangeUserEmail,
maybe_entity_type: Some(StaffAuditEntityType::User),
maybe_entity_token: Some(target_user_token.as_str()),
staff_user_token: &user_session.user_token,
actor_ip_address: &actor_ip,
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert staff audit log: {:?}", err);
CommonWebError::from_error(err)
})?;
// 5. Commit.
transaction.commit().await.map_err(|err| {
warn!("Failed to commit transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
info!(
"Email changed for user {} by moderator {}.",
target_user_token.as_str(), user_session.user_token.as_str(),
);
Ok(Json(ModeratorChangeUserEmailSuccessResponse { success: true }))
}
@@ -0,0 +1,194 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use chrono::{DateTime, Utc};
use log::warn;
use utoipa::{IntoParams, ToSchema};
use mysql_queries::queries::users::user::get::get_user_token_by_username::get_user_token_by_username;
use mysql_queries::queries::users::user_email_changes::list_user_email_changes_for_user::{
list_user_email_changes_for_user, ListUserEmailChangesForUserArgs, UserDisplay,
UserEmailChangeRow,
};
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "modemchg";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
// --- Path / Query ---
#[derive(Deserialize, ToSchema)]
pub struct ModeratorListUserEmailChangesForUserPathInfo {
pub username: String,
}
#[derive(Deserialize, IntoParams)]
pub struct ModeratorListUserEmailChangesQueryParams {
/// Opaque cursor from a previous page's `maybe_cursor`. Omit for the
/// first page.
pub cursor: Option<String>,
/// Page size. Defaults to 100, capped at 1000.
pub limit: Option<u32>,
}
// --- Response ---
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUserEmailChangesSuccessResponse {
pub success: bool,
pub changes: Vec<ModeratorUserEmailChangeItem>,
/// Opaque cursor for the next page. `None` means there are no more rows.
pub maybe_cursor: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorUserEmailChangeItem {
pub id: u64,
pub user: ModeratorUserEmailChangeUserSummary,
/// The user that performed the change. `None` for self-service changes
/// or when the actor's `users` row has been hard-deleted.
pub maybe_changed_by_user: Option<ModeratorUserEmailChangeUserSummary>,
pub old_email: String,
pub new_email: String,
pub ip_address: String,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorUserEmailChangeUserSummary {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub gravatar_hash: String,
}
// --- Handler ---
/// List recorded email-address changes for a given user, newest first.
/// Moderators only. Paginated via opaque cursor + limit.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_emails/list/{username}",
params(
("username" = String, description = "Username of the user whose email history to list"),
ModeratorListUserEmailChangesQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorListUserEmailChangesSuccessResponse),
(status = 400, description = "Bad cursor", body = CommonWebError),
(status = 401, description = "Unauthorized", body = CommonWebError),
(status = 404, description = "User not found", body = CommonWebError),
(status = 500, description = "Server error", body = CommonWebError),
),
)]
pub async fn moderator_list_email_address_changes_for_user_handler(
http_request: HttpRequest,
path: Path<ModeratorListUserEmailChangesForUserPathInfo>,
query: Query<ModeratorListUserEmailChangesQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserEmailChangesSuccessResponse>, CommonWebError> {
// 1. Require moderator.
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
// 2. Resolve username → user_token. Not-found → 404; everything else → 500.
let username = path.username.trim().to_lowercase();
let user_token = get_user_token_by_username(&username, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Failed to resolve username {:?}: {:?}", username, err);
CommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
warn!("User not found: {:?}", username);
CommonWebError::NotFound
})?;
// 3. Decode the page cursor, if any.
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
// 4. Fetch the email-change history for that user.
let rows = list_user_email_changes_for_user(ListUserEmailChangesForUserArgs {
user_token: &user_token,
maybe_cursor_id,
limit,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to list email changes for user {:?}: {:?}", username, err);
CommonWebError::from_error(err)
})?;
// 5. Encode the next-page cursor from the last id, if there was one.
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors
.encode_last_id_cursor(CURSOR_NAME, last.id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let changes = rows.into_iter().map(to_response_item).collect();
Ok(Json(ModeratorListUserEmailChangesSuccessResponse {
success: true,
changes,
maybe_cursor,
}))
}
fn to_response_item(row: UserEmailChangeRow) -> ModeratorUserEmailChangeItem {
ModeratorUserEmailChangeItem {
id: row.id,
user: to_user_summary(row.user),
maybe_changed_by_user: row.maybe_changed_by_user.map(to_user_summary),
old_email: row.old_email,
new_email: row.new_email,
ip_address: row.ip_address,
created_at: row.created_at,
}
}
fn to_user_summary(display: UserDisplay) -> ModeratorUserEmailChangeUserSummary {
ModeratorUserEmailChangeUserSummary {
user_token: display.token,
username: display.username,
display_name: display.display_name,
gravatar_hash: display.gravatar_hash,
}
}
@@ -28,6 +28,8 @@ 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::kill_tts_inference_jobs::kill_tts_inference_jobs_handler;
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler;
use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email::moderator_change_user_email_handler;
use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::moderator_list_email_address_changes_for_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;
@@ -216,6 +218,19 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(
web::scope("/user_emails")
.service(
web::resource("/change")
.route(web::post().to(moderator_change_user_email_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(
web::resource("/list/{username}")
.route(web::get().to(moderator_list_email_address_changes_for_user_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(
web::scope("/roles")
.service(