mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38cbd724d4 | ||
|
|
f669e0071e | ||
|
|
a8af0e8141 | ||
|
|
a78ac8c391 | ||
|
|
3a62afb53c | ||
|
|
998a7b60b1 | ||
|
|
aeebad3a40 | ||
|
|
bb53238c56 |
@@ -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;
|
||||
|
||||
+59
@@ -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(())
|
||||
}
|
||||
+208
@@ -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;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod moderator_change_user_email;
|
||||
pub mod moderator_list_email_address_changes_for_user;
|
||||
+179
@@ -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 }))
|
||||
}
|
||||
+194
@@ -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,
|
||||
}
|
||||
}
|
||||
+15
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user