Compare commits

...
Author SHA1 Message Date
Brandon Thomas d67d7df442 add api docs for impersonation 2026-04-13 22:57:41 -04:00
Brandon Thomas 50b1a7ed06 user impersonation for support inquiries (#1357)
- only done with user consent 
- only available to support staff
- thoroughly audited
2026-04-13 22:49:14 -04:00
Tanish Manku 44cadf84cc Surface gallery load errors with message and copy-to-clipboard (#1356)
When listUserMediaFiles fails (HTTP error, network error, null
reference, or any other exception), the gallery now shows the error
message with a "Copy error" button instead of silently falling through
to "No items yet".

This is a diagnostic aid for a macOS-only blank gallery bug where the
root cause is unknown (CORS, session cookie, or runtime exception) and
there is no access to production logs.
2026-04-13 16:23:49 -07:00
Brandon Thomas b7c8151d28 error utils (reusable) (#1355) 2026-04-13 17:47:06 -04:00
48 changed files with 1559 additions and 210 deletions
+5 -1
View File
@@ -6,7 +6,11 @@
- No minimum supported Rust version; use latest stable features freely
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code; new database queries should use `Result<T, sqlx::Error>` instead
- SQLx queries: prefer `sqlx::query!` and `sqlx::query_as!` compile-time checked macros over
runtime `sqlx::query()` / `sqlx::query_as()`. The macros catch SQL typos, column mismatches,
and type errors at build time. Use runtime queries only when assembling complicated
dynamic/branching queries that cannot be expressed as a compile-time macro.
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
@@ -0,0 +1,80 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token,\n impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonator_user_token as `impersonator_user_token: UserToken`,\n user_impersonation_token,\n is_redeemed as `is_redeemed: bool`,\n expires_at\nFROM user_impersonation_requests\nWHERE user_impersonation_token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "impersonated_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "impersonator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "user_impersonation_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "is_redeemed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "9dc83df0b7f1cf2716352e947b6b7fcb6c455fb0c12d72e584612f35366831f4"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n expires_at\n)\nVALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "ba5b0a542d4b95b781077b8895529d222dd682eb28c9d00031a820ec6f37e095"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO staff_audit_logs\nSET\n token = ?,\n audit_action = ?,\n maybe_entity_type = ?,\n maybe_entity_token = ?,\n staff_user_token = ?,\n staff_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "c39800423a5fe0aa324e117e624015f85d039c3ca6833cf6865471345ab0768e"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_impersonation_requests\nSET\n token = ?,\n impersonated_user_token = ?,\n impersonator_user_token = ?,\n user_impersonation_token = ?,\n ip_address_creation = ?,\n expires_at = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "cc782c807dadcaaaad08f469935f61dc2e9f3217aa01532d6602b8e37cedf92d"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE user_impersonation_requests\nSET\n is_redeemed = TRUE,\n ip_address_redemption = ?\nWHERE user_impersonation_token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cf4be26ce3a48c7108640faa6c9fc68a48136327a9d374c52c16848c006f0984"
}
+1 -1
View File
@@ -58,7 +58,7 @@ artcraft/
- Rust with no minimum supported version
- Actix-web for HTTP services
- SQLx for MySQL and SQLite
- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
Generated
+8
View File
@@ -3876,6 +3876,13 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "error_utils"
version = "0.0.1"
dependencies = [
"regex",
]
[[package]]
name = "errors"
version = "0.0.1"
@@ -11234,6 +11241,7 @@ dependencies = [
"elasticsearch",
"elasticsearch_schema",
"enums",
"error_utils",
"errors",
"fal_client",
"ffmpeg_utils",
+2
View File
@@ -59,6 +59,7 @@ members = [
"crates/lib/data/primitives",
"crates/lib/data/web_base64",
"crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve.
"crates/lib/error_utils",
"crates/lib/errors",
"crates/lib/ffmpeg_utils",
"crates/lib/files/file_to_base64_url",
@@ -236,6 +237,7 @@ cookie_store = { path = "crates/lib/cookie_store" }
crockford = { path = "crates/lib/data/crockford" }
easyenv = { path = "crates/lib/easyenv" }
enums = { path = "crates/schema/public/enums" }
error_utils = { path = "crates/lib/error_utils" }
errors = { path = "crates/lib/errors" }
fal_client = { path = "crates/api_clients/fal_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS staff_audit_logs;
@@ -0,0 +1,39 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE staff_audit_logs (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Effective "primary key" of the audit log item (in case we add a UI or notes)
token VARCHAR(32) NOT NULL,
-- The type of action taken
audit_action VARCHAR(32) NOT NULL,
-- The type of entity acted upon (if any)
maybe_entity_type VARCHAR(16) DEFAULT NULL,
-- The token of the entity mutated (if any)
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
-- For abuse tracking.
-- Wide enough for IPv4/6
staff_ip_address VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_maybe_entity_type (maybe_entity_type),
KEY index_maybe_entity_token (maybe_entity_token),
KEY index_staff_user_token (staff_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE user_sessions
DROP COLUMN maybe_impersonation_user_token;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE user_sessions
ADD COLUMN maybe_impersonation_user_token VARCHAR(32) DEFAULT NULL
AFTER user_token;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_impersonation_requests;
@@ -0,0 +1,44 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_impersonation_requests (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Internal "primary key"
token VARCHAR(32) NOT NULL,
-- The user being impersonated.
impersonated_user_token VARCHAR(32) NOT NULL,
-- The staff user who initiated the impersonation.
impersonator_user_token VARCHAR(32) NOT NULL,
-- The actual secret that must be presented to redeem the impersonation.
user_impersonation_token VARCHAR(32) NOT NULL,
-- Whether this request has been redeemed (used to create a session).
is_redeemed BOOLEAN NOT NULL DEFAULT FALSE,
-- IP address of the staff user who created the request.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address from which the request was redeemed, if redeemed.
ip_address_redemption VARCHAR(40) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- The request expires after this time if not redeemed.
expires_at TIMESTAMP NOT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (user_impersonation_token),
KEY index_impersonated_user_token (impersonated_user_token),
KEY index_impersonator_user_token (impersonator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,39 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE staff_audit_logs (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Effective "primary key" of the audit log item (in case we add a UI or notes)
token VARCHAR(32) NOT NULL,
-- The type of action taken
audit_action VARCHAR(32) NOT NULL,
-- The type of entity acted upon (if any)
maybe_entity_type VARCHAR(16) DEFAULT NULL,
-- The token of the entity mutated (if any)
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
-- For abuse tracking.
-- Wide enough for IPv4/6
staff_ip_address VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY index_maybe_entity_type (maybe_entity_type),
KEY index_maybe_entity_token (maybe_entity_token),
KEY index_staff_user_token (staff_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,44 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_impersonation_requests (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Internal "primary key"
token VARCHAR(32) NOT NULL,
-- The user being impersonated.
impersonated_user_token VARCHAR(32) NOT NULL,
-- The staff user who initiated the impersonation.
impersonator_user_token VARCHAR(32) NOT NULL,
-- The actual secret that must be presented to redeem the impersonation.
user_impersonation_token VARCHAR(32) NOT NULL,
-- Whether this request has been redeemed (used to create a session).
is_redeemed BOOLEAN NOT NULL DEFAULT FALSE,
-- IP address of the staff user who created the request.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address from which the request was redeemed, if redeemed.
ip_address_redemption VARCHAR(40) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- The request expires after this time if not redeemed.
expires_at TIMESTAMP NOT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (user_impersonation_token),
KEY index_impersonated_user_token (impersonated_user_token),
KEY index_impersonator_user_token (impersonator_user_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,50 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_sessions (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Session primary key
token VARCHAR(32) NOT NULL,
-- The user that the session belongs to.
user_token VARCHAR(32) NOT NULL,
-- NEVER IMPERSONATE A USER'S ACCOUNT WITHOUT THEIR CONSENT!
--
-- For support requests and inquiries, we can have staff log into user accounts
-- to verify and debug possible broken states. The user is always told about
-- this if we are to use this for support cases.
--
-- Every action performed by impersonated sessions is logged for auditing.
--
-- If this session is impersonating another user, this is the token of
-- the staff user who initiated the impersonation.
maybe_impersonation_user_token VARCHAR(32) DEFAULT NULL,
-- Track each session's creation IP.
-- Wide enough for IPv4/6
ip_address_creation VARCHAR(40) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Session termination time.
-- This must be set by the server code, or the session is invalid
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- deletion = session termination
-- Typically these are destroyed by users, but if in the future we allow mods to
-- delete them, it doesn't really matter who did the deletion: sessions are not
-- designed to be recoverable.
deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_user_token (user_token),
KEY index_ip_address_creation (ip_address_creation)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,11 @@
$kind: http-request
name: Impersonate User Request
url: "{{api_host}}/v1/moderation/user_sessions/impersonate"
method: POST
body:
type: json
content: |-
{
"username": "newuser3"
}
order: 4318829221021218
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "error_utils"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "error_utils"
path = "src/lib.rs"
[dependencies]
regex.workspace = true
+1
View File
@@ -0,0 +1 @@
pub mod try_error_name;
@@ -0,0 +1,157 @@
use regex::Regex;
use std::sync::LazyLock;
/// Regex that matches CamelCase type names with an optional `::Variant` suffix.
/// E.g. `CommonWebError::InvalidRequest`, `EnvError`, `KinoviError::RequestError`.
static ERROR_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*(?:::[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*)?")
.expect("error name regex should compile")
});
/// Try to extract a human-readable error title from the causal error's Debug
/// representation. Looks for CamelCase type names like `EnvError::NotUnicode`.
pub fn try_error_name(error: &dyn std::error::Error) -> Option<String> {
let debug_str = format!("{:?}", error);
try_extract_error_name(&debug_str)
}
/// Extract the leading CamelCase error name (with optional `::Variant`) from
/// a Debug-formatted error string. Returns `None` if no match is found.
///
/// ```text
/// "CommonWebError::InvalidRequest(\"bad\")" → Some("CommonWebError::InvalidRequest")
/// "EnvError::ParseError { reason: \"foo\" }" → Some("EnvError::ParseError")
/// "just a plain string" → None
/// ```
pub (crate) fn try_extract_error_name(debug_str: &str) -> Option<String> {
ERROR_NAME_RE.find(debug_str)
.map(|m| m.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
// --- try_extract_error_name tests ---
#[test]
fn extract_just_error_name() {
assert_eq!(
try_extract_error_name("JustErrorName"),
Some("JustErrorName".to_string()),
);
}
#[test]
fn extract_error_with_variant_and_string_payload() {
assert_eq!(
try_extract_error_name(r#"CommonWebError::InvalidRequest("Invalid request")"#),
Some("CommonWebError::InvalidRequest".to_string()),
);
}
#[test]
fn extract_error_with_unit_variant() {
assert_eq!(
try_extract_error_name("EnvError::NotUnicode"),
Some("EnvError::NotUnicode".to_string()),
);
}
#[test]
fn extract_error_with_struct_payload() {
assert_eq!(
try_extract_error_name(r#"EnvError::ParseError { reason: "foo" }"#),
Some("EnvError::ParseError".to_string()),
);
}
#[test]
fn extract_error_with_tuple_payload() {
assert_eq!(
try_extract_error_name("KinoviError::RequestError(asdf)"),
Some("KinoviError::RequestError".to_string()),
);
}
#[test]
fn extract_error_with_nested_errors() {
assert_eq!(
try_extract_error_name(
"ComplexError::SomeError(SomeError::SomeVariant(SomeInnerError::SomeInnerVariant))"
),
Some("ComplexError::SomeError".to_string()),
);
}
#[test]
fn extract_error_with_struct_containing_nested() {
assert_eq!(
try_extract_error_name(
"WrappedError::Variant { inner: SomeInnerError::SomeInnerVariant }"
),
Some("WrappedError::Variant".to_string()),
);
}
#[test]
fn no_match_for_lowercase_string() {
assert_eq!(
try_extract_error_name("just a plain error message with no type name"),
None,
);
}
#[test]
fn no_match_for_empty_string() {
assert_eq!(try_extract_error_name(""), None);
}
#[test]
fn single_word_camel_case() {
assert_eq!(
try_extract_error_name("Error"),
Some("Error".to_string()),
);
}
// --- try_error_title tests (with real error types) ---
#[test]
fn title_from_io_error() {
let err: Box<dyn std::error::Error> = Box::new(
std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
);
let title = try_error_name(err.as_ref());
assert!(title.is_some());
}
/// A simple custom error for testing.
#[derive(Debug)]
enum TestAppError {
DatabaseTimeout,
InvalidInput(String),
}
impl std::fmt::Display for TestAppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for TestAppError {}
#[test]
fn title_from_custom_unit_variant() {
let err = TestAppError::DatabaseTimeout;
let title = try_error_name(&err);
assert_eq!(title, Some("DatabaseTimeout".to_string()));
}
#[test]
fn title_from_custom_tuple_variant() {
let err = TestAppError::InvalidInput("bad data".to_string());
let title = try_error_name(&err);
assert_eq!(title, Some("InvalidInput".to_string()));
}
}
@@ -27,12 +27,14 @@ pub mod model_weights;
pub mod prompt_context_items;
pub mod prompts;
pub mod public_event_feed;
pub mod staff_audit_logs;
pub mod stats;
pub mod tags;
pub mod trending_model_analytics;
pub mod tts;
pub mod twitch;
pub mod unsubscribe_reason;
pub mod user_impersonation_requests;
pub mod users;
pub mod vocoder;
pub mod voice_clone_requests;
@@ -0,0 +1,54 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
use tokens::tokens::staff_audit_logs::StaffAuditLogToken;
use tokens::tokens::users::UserToken;
pub struct InsertStaffAuditLogArgs<'a, 'c: 'a, E>
where E: 'a + Executor<'c, Database = MySql>
{
pub audit_action: StaffAuditAction,
pub maybe_entity_type: Option<StaffAuditEntityType>,
pub maybe_entity_token: Option<&'a str>,
pub staff_user_token: &'a UserToken,
pub actor_ip_address: &'a str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_staff_audit_log<'a, 'c, E>(
args: InsertStaffAuditLogArgs<'a, 'c, E>,
) -> Result<StaffAuditLogToken, sqlx::Error>
where E: 'a + Executor<'c, Database = MySql>
{
let token = StaffAuditLogToken::generate();
sqlx::query!(
r#"
INSERT INTO staff_audit_logs
SET
token = ?,
audit_action = ?,
maybe_entity_type = ?,
maybe_entity_token = ?,
staff_user_token = ?,
staff_ip_address = ?
"#,
token.as_str(),
args.audit_action.to_str(),
args.maybe_entity_type.map(|ty| ty.to_str()),
args.maybe_entity_token,
args.staff_user_token.as_str(),
args.actor_ip_address,
)
.execute(args.mysql_executor)
.await?;
Ok(token)
}
@@ -0,0 +1 @@
pub mod insert_staff_audit_log;
@@ -0,0 +1,51 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::user_impersonation_requests::UserImpersonationRequestToken;
use tokens::tokens::users::UserToken;
pub struct InsertUserImpersonationRequestArgs<'a, 'c: 'a, E>
where E: 'a + Executor<'c, Database = MySql>
{
pub impersonated_user_token: &'a UserToken,
pub impersonator_user_token: &'a UserToken,
pub user_impersonation_token: &'a str,
pub ip_address_creation: &'a str,
pub expires_at: DateTime<Utc>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_user_impersonation_request<'a, 'c, E>(
args: InsertUserImpersonationRequestArgs<'a, 'c, E>,
) -> Result<UserImpersonationRequestToken, sqlx::Error>
where E: 'a + Executor<'c, Database = MySql>
{
let token = UserImpersonationRequestToken::generate();
sqlx::query!(
r#"
INSERT INTO user_impersonation_requests
SET
token = ?,
impersonated_user_token = ?,
impersonator_user_token = ?,
user_impersonation_token = ?,
ip_address_creation = ?,
expires_at = ?
"#,
token.as_str(),
args.impersonated_user_token.as_str(),
args.impersonator_user_token.as_str(),
args.user_impersonation_token,
args.ip_address_creation,
args.expires_at,
)
.execute(args.mysql_executor)
.await?;
Ok(token)
}
@@ -0,0 +1,40 @@
use chrono::{DateTime, Utc};
use sqlx::MySqlPool;
use tokens::tokens::users::UserToken;
pub struct UserImpersonationRequestRecord {
pub token: String,
pub impersonated_user_token: UserToken,
pub impersonator_user_token: UserToken,
pub user_impersonation_token: String,
pub is_redeemed: bool,
pub expires_at: DateTime<Utc>,
}
/// Look up a user impersonation request by the public impersonation token (the "password").
pub async fn lookup_user_impersonation_request(
user_impersonation_token: &str,
mysql_pool: &MySqlPool,
) -> Result<Option<UserImpersonationRequestRecord>, sqlx::Error> {
let maybe_record = sqlx::query_as!(
UserImpersonationRequestRecord,
r#"
SELECT
token,
impersonated_user_token as `impersonated_user_token: UserToken`,
impersonator_user_token as `impersonator_user_token: UserToken`,
user_impersonation_token,
is_redeemed as `is_redeemed: bool`,
expires_at
FROM user_impersonation_requests
WHERE user_impersonation_token = ?
LIMIT 1
"#,
user_impersonation_token,
)
.fetch_optional(mysql_pool)
.await?;
Ok(maybe_record)
}
@@ -0,0 +1,35 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
pub struct MarkImpersonationTokenAsRedeemedArgs<'a, 'c: 'a, E>
where E: 'a + Executor<'c, Database = MySql>
{
pub user_impersonation_token: &'a str,
pub ip_address_redemption: &'a str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn mark_impersonation_token_as_redeemed<'a, 'c, E>(
args: MarkImpersonationTokenAsRedeemedArgs<'a, 'c, E>,
) -> Result<(), sqlx::Error>
where E: 'a + Executor<'c, Database = MySql>
{
sqlx::query!(
r#"
UPDATE user_impersonation_requests
SET
is_redeemed = TRUE,
ip_address_redemption = ?
WHERE user_impersonation_token = ?
"#,
args.ip_address_redemption,
args.user_impersonation_token,
)
.execute(args.mysql_executor)
.await?;
Ok(())
}
@@ -0,0 +1,3 @@
pub mod insert_user_impersonation_request;
pub mod lookup_user_impersonation_request;
pub mod mark_impersonation_token_as_redeemed;
@@ -0,0 +1,53 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
pub struct CreateImpersonatedUserSessionArgs<'a, 'c: 'a, E>
where E: 'a + Executor<'c, Database = MySql>
{
/// The user being impersonated (the session will act as this user).
pub user_token: &'a UserToken,
/// The staff/moderator user who initiated the impersonation.
pub impersonator_user_token: &'a UserToken,
pub ip_address: &'a str,
pub expires_at: DateTime<Utc>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn create_impersonated_user_session<'a, 'c, E>(
args: CreateImpersonatedUserSessionArgs<'a, 'c, E>,
) -> Result<UserSessionToken, sqlx::Error>
where E: 'a + Executor<'c, Database = MySql>
{
let session_token = UserSessionToken::generate();
sqlx::query!(
r#"
INSERT INTO user_sessions (
token,
user_token,
maybe_impersonation_user_token,
ip_address_creation,
expires_at
)
VALUES (?, ?, ?, ?, ?)
"#,
session_token.as_str(),
args.user_token.as_str(),
args.impersonator_user_token.as_str(),
args.ip_address,
args.expires_at,
)
.execute(args.mysql_executor)
.await?;
Ok(session_token)
}
@@ -1,3 +1,4 @@
pub mod create_impersonated_user_session;
pub mod create_user_session;
pub mod create_user_session_with_transactor;
pub mod delete_user_session;
@@ -16,6 +16,7 @@ pub mod model_categories;
pub mod model_weights;
pub mod prompt_context_items;
pub mod prompts;
pub mod staff_audit_logs;
pub mod tag_uses;
pub mod trending_model_analytics;
pub mod tts_models;
@@ -0,0 +1,2 @@
pub mod staff_audit_action;
pub mod staff_audit_entity_type;
@@ -0,0 +1,115 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
/// Used in the `staff_audit_logs` table in `VARCHAR(32)` field `entity_action`.
///
/// The type of staff action that was performed.
///
/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)]
pub enum StaffAuditAction {
/// Staff initiated an impersonation request for a user.
#[serde(rename = "impersonate_user_request")]
ImpersonateUserRequest,
/// Staff redeemed an impersonation request, creating a session.
#[serde(rename = "impersonate_user_redeem")]
ImpersonateUserRedeem,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
impl_mysql_enum_coders!(StaffAuditAction);
impl_mysql_from_row!(StaffAuditAction);
impl StaffAuditAction {
pub fn to_str(&self) -> &'static str {
match self {
Self::ImpersonateUserRequest => "impersonate_user_request",
Self::ImpersonateUserRedeem => "impersonate_user_redeem",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"impersonate_user_request" => Ok(Self::ImpersonateUserRequest),
"impersonate_user_redeem" => Ok(Self::ImpersonateUserRedeem),
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::ImpersonateUserRequest,
Self::ImpersonateUserRedeem,
])
}
}
#[cfg(test)]
mod tests {
use crate::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
assert_serialization(StaffAuditAction::ImpersonateUserRequest, "impersonate_user_request");
assert_serialization(StaffAuditAction::ImpersonateUserRedeem, "impersonate_user_redeem");
}
#[test]
fn to_str() {
assert_eq!(StaffAuditAction::ImpersonateUserRequest.to_str(), "impersonate_user_request");
assert_eq!(StaffAuditAction::ImpersonateUserRedeem.to_str(), "impersonate_user_redeem");
}
#[test]
fn from_str() {
assert_eq!(StaffAuditAction::from_str("impersonate_user_request").unwrap(), StaffAuditAction::ImpersonateUserRequest);
assert_eq!(StaffAuditAction::from_str("impersonate_user_redeem").unwrap(), StaffAuditAction::ImpersonateUserRedeem);
assert!(StaffAuditAction::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 2;
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(StaffAuditAction::all_variants().len(), StaffAuditAction::iter().len());
}
#[test]
fn round_trip() {
for variant in StaffAuditAction::all_variants() {
assert_eq!(variant, StaffAuditAction::from_str(variant.to_str()).unwrap());
assert_eq!(variant, StaffAuditAction::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, StaffAuditAction::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH: usize = 32;
for variant in StaffAuditAction::all_variants() {
let serialized = variant.to_str();
assert!(!serialized.is_empty(), "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH);
}
}
}
}
@@ -0,0 +1,105 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
/// Used in the `staff_audit_logs` table in `VARCHAR(32)` field `entity_type`.
///
/// The type of entity that a staff action was performed on.
///
/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)]
pub enum StaffAuditEntityType {
/// A user account.
#[serde(rename = "user")]
User,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditEntityType);
impl_mysql_enum_coders!(StaffAuditEntityType);
impl_mysql_from_row!(StaffAuditEntityType);
impl StaffAuditEntityType {
pub fn to_str(&self) -> &'static str {
match self {
Self::User => "user",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"user" => Ok(Self::User),
_ => Err(format!("invalid StaffAuditEntityType value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::User,
])
}
}
#[cfg(test)]
mod tests {
use crate::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
assert_serialization(StaffAuditEntityType::User, "user");
}
#[test]
fn to_str() {
assert_eq!(StaffAuditEntityType::User.to_str(), "user");
}
#[test]
fn from_str() {
assert_eq!(StaffAuditEntityType::from_str("user").unwrap(), StaffAuditEntityType::User);
assert!(StaffAuditEntityType::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 1;
assert_eq!(StaffAuditEntityType::all_variants().len(), EXPECTED_COUNT);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(StaffAuditEntityType::all_variants().len(), StaffAuditEntityType::iter().len());
}
#[test]
fn round_trip() {
for variant in StaffAuditEntityType::all_variants() {
assert_eq!(variant, StaffAuditEntityType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, StaffAuditEntityType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, StaffAuditEntityType::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH: usize = 32;
for variant in StaffAuditEntityType::all_variants() {
let serialized = variant.to_str();
assert!(!serialized.is_empty(), "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH);
}
}
}
}
@@ -40,9 +40,11 @@ pub(crate) enum TokenPrefix {
NewsStory, // NB: aichatbot / sqlite
PasswordReset,
Prompt,
StaffAuditLog,
Tag,
TtsRenderTask, // NB: aichatbot / sqlite
User,
UserImpersonationRequest,
UserBookmark,
UserSession,
UserSubscription,
@@ -124,9 +126,11 @@ impl PrefixGenerator for TokenPrefix {
Self::NewsStory => "news_story_",
Self::PasswordReset => "pw_reset_",
Self::Prompt => "prompt_",
Self::StaffAuditLog => "stfaud_",
Self::Tag => "tag_",
Self::TtsRenderTask => "tts_task_",
Self::User => "user_", // NB: Previously "U:"
Self::UserImpersonationRequest => "uimpr_",
Self::UserBookmark => "ub_",
Self::UserSession => "session_",
Self::UserSubscription => "subscription_", // NB: Previously "SUB:"
@@ -20,6 +20,7 @@ pub mod model_weights;
pub mod password_reset;
pub mod prompts;
pub mod sqlite;
pub mod staff_audit_logs;
pub mod tags;
pub mod tts_inference_jobs;
pub mod tts_model_upload_jobs;
@@ -29,6 +30,7 @@ pub mod twitch_event_rule;
pub mod twitch_oauth_tokens_grouping;
pub mod twitch_oauth_tokens_internal;
pub mod user_bookmarks;
pub mod user_impersonation_requests;
pub mod user_sessions;
pub mod user_subscriptions;
pub mod users;
@@ -0,0 +1,17 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The primary key for Staff Audit Logs
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct StaffAuditLogToken(pub String);
impl_string_token!(StaffAuditLogToken);
impl_mysql_token_from_row!(StaffAuditLogToken);
impl_crockford_generator!(StaffAuditLogToken, 32usize, TokenPrefix::StaffAuditLog, CrockfordLower);
@@ -0,0 +1,17 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The primary key for User Impersonation Requests
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct UserImpersonationRequestToken(pub String);
impl_string_token!(UserImpersonationRequestToken);
impl_mysql_token_from_row!(UserImpersonationRequestToken);
impl_crockford_generator!(UserImpersonationRequestToken, 32usize, TokenPrefix::UserImpersonationRequest, CrockfordLower);
@@ -50,6 +50,7 @@ datetimes = { path = "../../../lib/data/datetimes" }
easyenv.workspace = true
elasticsearch_schema = { path = "../../../schema/database/elasticsearch_schema" }
enums.workspace = true
error_utils.workspace = true
errors.workspace = true
fal_client.workspace = true
filesys.workspace = true
@@ -86,6 +86,7 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_error::Medi
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_sessions::moderator_user_session_impersonation_request_handler::*;
use crate::http_server::endpoints::prompts::get_prompt_handler::*;
use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest;
use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest;
@@ -507,6 +508,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::wallets::moderator_add_banked_balance_to_wallet_handler::moderator_add_banked_balance_to_wallet_handler,
crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler,
crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler,
// Credits
crate::http_server::endpoints::credits::get_session_credits_handler::get_session_credits_handler,
// Subscriptions
@@ -931,6 +933,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
MediaFilesByBatchListItem,
ModelWeightForList,
ModelWeightSearchResult,
ModerationImpersonateRequest,
ModerationImpersonateSuccessResponse,
ModernInferenceQueueStats,
PinnedMediaFile,
PinnedModelWeightForList,
@@ -5,5 +5,6 @@ pub mod jobs;
pub mod user;
pub mod user_bans;
pub mod user_feature_flags;
pub mod user_sessions;
pub mod wallet_ledger_entries;
pub mod wallets;
@@ -0,0 +1 @@
pub mod moderator_user_session_impersonation_request_handler;
@@ -0,0 +1,273 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use chrono::{Duration, Utc};
use log::{info, warn};
use utoipa::ToSchema;
use crockford::crockford_entropy_lower;
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::user_impersonation_requests::insert_user_impersonation_request::{
insert_user_impersonation_request, InsertUserImpersonationRequestArgs,
};
use mysql_queries::queries::users::user::get::lookup_user_for_moderation::{
lookup_user_for_moderation_by_email,
lookup_user_for_moderation_by_token,
lookup_user_for_moderation_by_username,
LookupUserForModerationResult,
};
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;
// --- Request ---
#[derive(Deserialize, ToSchema)]
pub struct ModerationImpersonateRequest {
pub username: Option<String>,
pub user_token: Option<String>,
pub email_address: Option<String>,
pub username_email_or_token: Option<String>,
}
// --- Response ---
#[derive(Serialize, ToSchema)]
pub struct ModerationImpersonateSuccessResponse {
pub success: bool,
pub password_token: String,
}
// --- Handler ---
/// Create a user impersonation request. Moderators only.
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/user_sessions/impersonate",
request_body = ModerationImpersonateRequest,
responses(
(status = 200, description = "Impersonation request created", body = ModerationImpersonateSuccessResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "User not found"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_user_session_impersonation_request_handler(
http_request: HttpRequest,
request: Json<ModerationImpersonateRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationImpersonateSuccessResponse>, AdvancedCommonWebError> {
// 1. Require moderator session.
let user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
if user_session.is_banned {
warn!("Banned moderator tried to impersonate: {}", user_session.user_token.as_str());
return Err(AdvancedCommonWebError::NotAuthorized);
}
// 2. Determine lookup strategy from the request.
let lookup = resolve_lookup_strategy(&request)?;
info!(
"Moderator {} requesting impersonation: {:?}",
user_session.user_token.as_str(),
lookup,
);
// 3. Look up the target user.
let target_user = perform_user_lookup(&lookup, &server_state).await?;
// 4. Validate target user state.
if target_user.is_banned {
warn!(
"Moderator {} tried to impersonate banned user {}",
user_session.user_token.as_str(),
target_user.user_token.as_str(),
);
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"Target user is banned".to_string(),
));
}
// 5. Generate the secret password token.
let password_token = crockford_entropy_lower(16);
// 6. IP address for auditing.
let ip_address = get_request_ip(&http_request);
// 7. Begin transaction.
let mut transaction = server_state.mysql_pool.begin().await
.map_err(|err| {
warn!("Failed to begin transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
// 8. Insert staff audit log.
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::ImpersonateUserRequest,
maybe_entity_type: Some(StaffAuditEntityType::User),
maybe_entity_token: Some(target_user.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);
AdvancedCommonWebError::from_error(err)
})?;
// 9. Insert impersonation request (expires in 20 minutes).
let expires_at = Utc::now() + Duration::minutes(20);
let _impersonation_token = insert_user_impersonation_request(
InsertUserImpersonationRequestArgs {
impersonated_user_token: &target_user.user_token,
impersonator_user_token: &user_session.user_token,
user_impersonation_token: &password_token,
ip_address_creation: &ip_address,
expires_at,
mysql_executor: &mut *transaction,
phantom: PhantomData,
},
).await.map_err(|err| {
warn!("Failed to insert impersonation request: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
// 10. Commit transaction.
transaction.commit().await.map_err(|err| {
warn!("Failed to commit transaction: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
info!(
"Impersonation request created: moderator={} target_user={} target_username={}",
user_session.user_token.as_str(),
target_user.user_token.as_str(),
target_user.username,
);
// 11. Respond with the password token.
Ok(Json(ModerationImpersonateSuccessResponse {
success: true,
password_token,
}))
}
// --- Helpers ---
/// Which field to look up the user by, after normalization.
#[derive(Debug)]
enum UserLookup {
Username(String),
Email(String),
Token(String),
}
/// Resolve which lookup field was provided. Exactly one must be set.
fn resolve_lookup_strategy(
request: &ModerationImpersonateRequest,
) -> Result<UserLookup, AdvancedCommonWebError> {
let mut fields_set = 0u8;
if request.username.is_some() { fields_set += 1; }
if request.user_token.is_some() { fields_set += 1; }
if request.email_address.is_some() { fields_set += 1; }
if request.username_email_or_token.is_some() { fields_set += 1; }
if fields_set == 0 {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"Provide one of: username, user_token, email_address, or username_email_or_token".to_string(),
));
}
if fields_set > 1 {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"Only one lookup field should be provided".to_string(),
));
}
if let Some(ref username) = request.username {
return Ok(UserLookup::Username(username.trim().to_lowercase()));
}
if let Some(ref token) = request.user_token {
return Ok(UserLookup::Token(token.trim().to_string()));
}
if let Some(ref email) = request.email_address {
return Ok(UserLookup::Email(email.trim().to_lowercase()));
}
if let Some(ref value) = request.username_email_or_token {
return Ok(classify_ambiguous_lookup(value));
}
Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"No lookup field provided".to_string(),
))
}
/// Classify an ambiguous lookup value as username, email, or token.
fn classify_ambiguous_lookup(value: &str) -> UserLookup {
let trimmed = value.trim();
if trimmed.contains('@') {
return UserLookup::Email(trimmed.to_lowercase());
}
if trimmed.starts_with("user_") || trimmed.starts_with("U:") {
return UserLookup::Token(trimmed.to_string());
}
UserLookup::Username(trimmed.to_lowercase())
}
async fn perform_user_lookup(
lookup: &UserLookup,
server_state: &ServerState,
) -> Result<LookupUserForModerationResult, AdvancedCommonWebError> {
let maybe_user = match lookup {
UserLookup::Username(username) => {
lookup_user_for_moderation_by_username(username, &server_state.mysql_pool).await
}
UserLookup::Email(email) => {
lookup_user_for_moderation_by_email(email, &server_state.mysql_pool).await
}
UserLookup::Token(token) => {
lookup_user_for_moderation_by_token(token, &server_state.mysql_pool).await
}
};
match maybe_user {
Ok(Some(user)) => Ok(user),
Ok(None) => {
warn!("User not found for impersonation lookup: {:?}", lookup);
Err(AdvancedCommonWebError::NotFound)
}
Err(err) => {
warn!("User lookup error: {:?}", err);
Err(AdvancedCommonWebError::from_anyhow_error(err))
}
}
}
@@ -1,15 +1,29 @@
use std::fmt;
use std::fmt::Formatter;
use std::marker::PhantomData;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::{Duration, Utc};
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 http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use log::{info, warn};
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{
insert_staff_audit_log, InsertStaffAuditLogArgs,
};
use mysql_queries::queries::user_impersonation_requests::lookup_user_impersonation_request::lookup_user_impersonation_request;
use mysql_queries::queries::user_impersonation_requests::mark_impersonation_token_as_redeemed::{
mark_impersonation_token_as_redeemed, MarkImpersonationTokenAsRedeemedArgs,
};
use mysql_queries::queries::users::user::get::lookup_user_for_login_by_email::lookup_user_for_login_by_email;
use mysql_queries::queries::users::user::get::lookup_user_for_login_by_username::lookup_user_for_login_by_username;
use mysql_queries::queries::users::user_sessions::create_impersonated_user_session::{
create_impersonated_user_session, CreateImpersonatedUserSessionArgs,
};
use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session;
use password::bcrypt_confirm_password::bcrypt_confirm_password;
use sqlx::MySqlPool;
@@ -18,6 +32,7 @@ use utoipa::ToSchema;
use artcraft_api_defs::users::login::{LoginErrorType, LoginRequest, LoginSuccessResponse};
use password::errors::password_confirm_error::PasswordConfirmError;
use tokens::tokens::users::UserToken;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::util::enroll_in_studio::enroll_in_studio;
@@ -130,51 +145,56 @@ pub async fn login_handler(
}
};
match bcrypt_confirm_password(request.password.clone(), &actual_hash) {
let ip_address = get_request_ip(&http_request);
// Determine the password error to return if impersonation also fails.
let password_error = match bcrypt_confirm_password(request.password.clone(), &actual_hash) {
Ok(true) => None, // Password is valid — normal login.
Ok(false) => Some(LoginErrorResponse::invalid_credentials()),
Err(PasswordConfirmError::HashIsSentinelValue) => {
warn!("Attempting to log in with a passwordless account");
return Err(LoginErrorResponse::account_without_password());
Some(LoginErrorResponse::account_without_password())
}
Err(e) => {
warn!("Login hash comparison error: {:?}", e);
return Err(LoginErrorResponse::server_error());
}
Ok(is_valid) => {
if !is_valid {
info!("invalid credentials");
return Err(LoginErrorResponse::invalid_credentials());
}
// Good to go...!
},
}
let ip_address = get_request_ip(&http_request);
let create_session_result =
create_user_session(&user.token.0, &ip_address, &mysql_pool).await;
let session_token = match create_session_result {
Ok(token) => token,
Err(e) => {
warn!("login create session error : {:?}", e);
return Err(LoginErrorResponse::server_error());
Some(LoginErrorResponse::server_error())
}
};
info!("login session created for user: {} / {:?}", &user.token, &user.token);
let session_token = if password_error.is_none() {
// Normal login path — password matched.
let create_session_result =
create_user_session(&user.token.0, &ip_address, &mysql_pool).await;
let token = match create_session_result {
Ok(token) => token,
Err(e) => {
warn!("login create session error: {:?}", e);
return Err(LoginErrorResponse::server_error());
}
};
let user_feature_flags =
UserSessionFeatureFlags::new(user.maybe_feature_flags.as_deref());
info!("login session created for user: {} / {:?}", &user.token, &user.token);
// NB: Enroll new users in studio for a while.
enroll_in_studio(&user.token, &ip_address, &mysql_pool, Some(&user_feature_flags)).await
.map_err(|e| {
warn!("error enrolling in studio: {:?}", e);
}).ok();
let user_feature_flags =
UserSessionFeatureFlags::new(user.maybe_feature_flags.as_deref());
// NB: Enroll new users in studio for a while.
enroll_in_studio(&user.token, &ip_address, &mysql_pool, Some(&user_feature_flags)).await
.map_err(|e| {
warn!("error enrolling in studio: {:?}", e);
}).ok();
let session_token = UserSessionToken::new_from_str(&session_token);
UserSessionToken::new_from_str(&token)
} else {
// Password didn't match — try impersonation token as a fallback.
match try_impersonation_login(&user.token, &request.password, &ip_address, &mysql_pool).await {
Ok(session_token) => session_token,
Err(_) => {
// Impersonation also failed; return the original password error.
return Err(password_error.unwrap());
}
}
};
let session_cookie = match session_cookie_manager.create_cookie(&session_token, &user.token) {
Ok(cookie) => cookie,
@@ -199,3 +219,118 @@ pub async fn login_handler(
.content_type("application/json")
.body(body))
}
/// Attempt to log in via a moderator impersonation token (supplied in the password field).
///
/// Returns the impersonated user's session token on success, or an error if the
/// impersonation token is invalid, expired, already redeemed, or doesn't match.
async fn try_impersonation_login(
target_user_token: &UserToken,
password: &str,
ip_address: &str,
mysql_pool: &MySqlPool,
) -> Result<UserSessionToken, LoginErrorResponse> {
let record = lookup_user_impersonation_request(password, mysql_pool)
.await
.map_err(|err| {
warn!("Impersonation lookup error: {:?}", err);
LoginErrorResponse::server_error()
})?
.ok_or_else(|| {
info!("No impersonation request found for provided token");
LoginErrorResponse::invalid_credentials()
})?;
// Validate: not already redeemed.
if record.is_redeemed {
warn!("Impersonation token already redeemed: {}", record.token);
return Err(LoginErrorResponse::invalid_credentials());
}
// Validate: not expired.
if Utc::now() > record.expires_at {
warn!("Impersonation token expired: {} (expired at {:?})", record.token, record.expires_at);
return Err(LoginErrorResponse::invalid_credentials());
}
// Validate: the impersonated user matches the user we're logging into.
if record.impersonated_user_token != *target_user_token {
warn!(
"Impersonation token user mismatch: token is for {} but login is for {}",
record.impersonated_user_token.as_str(),
target_user_token.as_str(),
);
return Err(LoginErrorResponse::invalid_credentials());
}
info!(
"Valid impersonation login: moderator={} impersonating={}",
record.impersonator_user_token.as_str(),
target_user_token.as_str(),
);
// Begin transaction: audit log + mark redeemed + create session.
let mut transaction = mysql_pool.begin().await
.map_err(|err| {
warn!("Failed to begin impersonation transaction: {:?}", err);
LoginErrorResponse::server_error()
})?;
// Insert staff audit log for the redemption.
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::ImpersonateUserRedeem,
maybe_entity_type: Some(StaffAuditEntityType::User),
maybe_entity_token: Some(target_user_token.as_str()),
staff_user_token: &record.impersonator_user_token,
actor_ip_address: ip_address,
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert impersonation audit log: {:?}", err);
LoginErrorResponse::server_error()
})?;
// Mark the impersonation token as redeemed.
mark_impersonation_token_as_redeemed(MarkImpersonationTokenAsRedeemedArgs {
user_impersonation_token: password,
ip_address_redemption: ip_address,
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to mark impersonation token as redeemed: {:?}", err);
LoginErrorResponse::server_error()
})?;
// Create the impersonated session (60 min expiry).
let expires_at = Utc::now() + Duration::minutes(60);
let session_token = create_impersonated_user_session(
CreateImpersonatedUserSessionArgs {
user_token: target_user_token,
impersonator_user_token: &record.impersonator_user_token,
ip_address,
expires_at,
mysql_executor: &mut *transaction,
phantom: PhantomData,
},
).await.map_err(|err| {
warn!("Failed to create impersonated session: {:?}", err);
LoginErrorResponse::server_error()
})?;
// Commit the transaction.
transaction.commit().await.map_err(|err| {
warn!("Failed to commit impersonation transaction: {:?}", err);
LoginErrorResponse::server_error()
})?;
info!(
"Impersonation session created: moderator={} as user={} session={}",
record.impersonator_user_token.as_str(),
target_user_token.as_str(),
session_token.as_str(),
);
Ok(session_token)
}
@@ -1,6 +1,5 @@
use error_utils::try_error_name::try_error_name;
use log::{debug, warn};
use regex::Regex;
use std::sync::LazyLock;
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
@@ -24,7 +23,7 @@ pub(crate) fn check_advanced_common_web_error(
}
let maybe_error_name = error.cause()
.and_then(|cause| try_error_title(cause));
.and_then(|cause| try_error_name(cause));
let title = match maybe_error_name {
Some(name) => format!("{} on {} {}", name, method, path),
@@ -61,174 +60,3 @@ pub(crate) fn check_advanced_common_web_error(
true
}
/// Try to extract a human-readable error title from the causal error's Debug
/// representation. Looks for CamelCase type names like `EnvError::NotUnicode`.
fn try_error_title(error: &dyn std::error::Error) -> Option<String> {
let debug_str = format!("{:?}", error);
try_extract_error_name(&debug_str)
}
/// Regex that matches CamelCase type names with an optional `::Variant` suffix.
/// E.g. `CommonWebError::InvalidRequest`, `EnvError`, `KinoviError::RequestError`.
static ERROR_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*(?:::[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*)?")
.expect("error name regex should compile")
});
/// Extract the leading CamelCase error name (with optional `::Variant`) from
/// a Debug-formatted error string. Returns `None` if no match is found.
///
/// ```text
/// "CommonWebError::InvalidRequest(\"bad\")" → Some("CommonWebError::InvalidRequest")
/// "EnvError::ParseError { reason: \"foo\" }" → Some("EnvError::ParseError")
/// "just a plain string" → None
/// ```
fn try_extract_error_name(debug_str: &str) -> Option<String> {
ERROR_NAME_RE.find(debug_str)
.map(|m| m.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
// --- try_extract_error_name tests ---
#[test]
fn extract_just_error_name() {
assert_eq!(
try_extract_error_name("JustErrorName"),
Some("JustErrorName".to_string()),
);
}
#[test]
fn extract_error_with_variant_and_string_payload() {
assert_eq!(
try_extract_error_name(r#"CommonWebError::InvalidRequest("Invalid request")"#),
Some("CommonWebError::InvalidRequest".to_string()),
);
}
#[test]
fn extract_error_with_unit_variant() {
assert_eq!(
try_extract_error_name("EnvError::NotUnicode"),
Some("EnvError::NotUnicode".to_string()),
);
}
#[test]
fn extract_error_with_struct_payload() {
assert_eq!(
try_extract_error_name(r#"EnvError::ParseError { reason: "foo" }"#),
Some("EnvError::ParseError".to_string()),
);
}
#[test]
fn extract_error_with_tuple_payload() {
assert_eq!(
try_extract_error_name("KinoviError::RequestError(asdf)"),
Some("KinoviError::RequestError".to_string()),
);
}
#[test]
fn extract_error_with_nested_errors() {
// Should only grab the outermost error name.
assert_eq!(
try_extract_error_name(
"ComplexError::SomeError(SomeError::SomeVariant(SomeInnerError::SomeInnerVariant))"
),
Some("ComplexError::SomeError".to_string()),
);
}
#[test]
fn extract_error_with_struct_containing_nested() {
assert_eq!(
try_extract_error_name(
"WrappedError::Variant { inner: SomeInnerError::SomeInnerVariant }"
),
Some("WrappedError::Variant".to_string()),
);
}
#[test]
fn no_match_for_lowercase_string() {
assert_eq!(
try_extract_error_name("just a plain error message with no type name"),
None,
);
}
#[test]
fn no_match_for_empty_string() {
assert_eq!(try_extract_error_name(""), None);
}
#[test]
fn single_word_camel_case() {
// A single CamelCase word like "Error" should not match because
// the regex requires at least two segments (e.g. "Ab" = A + b).
// "Error" = E + rror — "rror" has 4 lowercase chars which is fine.
assert_eq!(
try_extract_error_name("Error"),
Some("Error".to_string()),
);
}
// --- try_error_title tests (with real error types) ---
#[test]
fn title_from_io_error() {
let err: Box<dyn std::error::Error> = Box::new(
std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
);
// io::Error Debug looks like: `Custom { kind: NotFound, error: "file not found" }`
// "Custom" doesn't match CamelCase with 2+ segments... but we get what we get.
let title = try_error_title(err.as_ref());
// The exact output depends on Debug formatting. Just verify it doesn't panic.
// Custom has one uppercase + lowercase sequence = "Custom" which matches [A-Z][0-9a-z]+
assert!(title.is_some());
}
#[test]
fn title_from_serde_json_error() {
let err: serde_json::Error = serde_json::from_str::<String>("not json").unwrap_err();
let title = try_error_title(&err);
// serde_json::Error Debug typically starts with "Error(...)"
assert!(title.is_some());
}
/// A simple custom error for testing.
#[derive(Debug)]
enum TestAppError {
DatabaseTimeout,
InvalidInput(String),
}
impl std::fmt::Display for TestAppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for TestAppError {}
#[test]
fn title_from_custom_unit_variant() {
let err = TestAppError::DatabaseTimeout;
let title = try_error_title(&err);
assert_eq!(title, Some("DatabaseTimeout".to_string()));
}
#[test]
fn title_from_custom_tuple_variant() {
let err = TestAppError::InvalidInput("bad data".to_string());
let title = try_error_title(&err);
assert_eq!(title, Some("InvalidInput".to_string()));
}
}
@@ -36,6 +36,7 @@ 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::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler;
use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler;
pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
where
@@ -103,6 +104,12 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/user_sessions")
.service(web::resource("/impersonate")
.route(web::post().to(moderator_user_session_impersonation_request_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/wallet_ledger_entry/{wallet_ledger_entry_token}")
.route(web::get().to(moderator_get_wallet_ledger_entry_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -401,6 +401,9 @@ export const GalleryModal = React.memo(
// Track whether this is the very first load (no cache, no items yet)
const [initialLoading, setInitialLoading] = useState(false);
// Error message from failed listUserMediaFiles call (null = no error)
const [itemsLoadError, setItemsLoadError] = useState<string | null>(null);
// Bulk selection state (view mode only)
const [bulkSelectedIds, setBulkSelectedIds] = useState<Set<string>>(
new Set(),
@@ -545,6 +548,7 @@ export const GalleryModal = React.memo(
const response = await api.listUserMediaFiles(query);
if (response.success && response.data) {
setItemsLoadError(null);
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
@@ -589,9 +593,13 @@ export const GalleryModal = React.memo(
});
return latest;
});
} else {
setItemsLoadError(response.errorMessage || "Request failed (unknown error)");
}
} catch (error) {
console.error("Failed to fetch library items:", error);
const msg = error instanceof Error ? error.message : String(error);
console.error("Failed to fetch library items:", msg);
setItemsLoadError(msg);
}
setLoading(false);
setPaginationLoading(false);
@@ -603,6 +611,7 @@ export const GalleryModal = React.memo(
// refresh logic — shows cached items immediately, then background-refreshes
const refreshGallery = useCallback(() => {
setItemsLoadError(null);
const cacheKey = `gallery_${activeFilterRef.current}`;
const cached = galleryCacheMap.get(cacheKey);
@@ -1139,6 +1148,27 @@ export const GalleryModal = React.memo(
</button>
</div>
</div>
) : itemsLoadError && allItems.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3 text-sm max-w-xs text-center">
<div className="text-base-fg/60">Failed to load gallery.</div>
<div className="text-xs text-base-fg/40 font-mono break-all">{itemsLoadError}</div>
<div className="flex gap-3">
<button
className="text-xs text-blue-400 hover:text-blue-300 underline"
onClick={() => navigator.clipboard?.writeText(itemsLoadError!)}
>
Copy error
</button>
<button
className="text-xs text-blue-400 hover:text-blue-300 underline"
onClick={() => refreshGallery()}
>
Retry
</button>
</div>
</div>
</div>
) : (initialLoading || !username) && allItems.length === 0 ? (
<SkeletonGrid columns={gridColumns} />
) : allItems.length === 0 && !loading ? (