mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 09:22:41 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f38d71e8e | ||
|
|
079ccacd51 | ||
|
|
af763669cc | ||
|
|
7ff78c6d94 | ||
|
|
588478da73 | ||
|
|
5cb7b80b44 | ||
|
|
50b1a7ed06 | ||
|
|
44cadf84cc | ||
|
|
b7c8151d28 |
@@ -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)`
|
||||
@@ -56,8 +60,14 @@ then the story, then the fine print.
|
||||
`use artcraft_router::...::CommonResolution as CommonResolutionRouter;`
|
||||
This avoids long fully-qualified paths in function signatures and bodies.
|
||||
|
||||
## MySQL
|
||||
|
||||
- MySQL tables: primary key `id` columns must be `BIGINT(20) UNSIGNED`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
|
||||
## TypeScript / Frontend
|
||||
|
||||
- TypeScript with React, Vite, Zustand, Three.js
|
||||
- Nx monorepo under `crates/frontend/`
|
||||
- Two spaces for indentation
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nWHERE uir.impersonated_user_token = ?\n AND uir.id < ?\nORDER BY uir.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"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": 2,
|
||||
"name": "impersonator_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "impersonator_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"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": 5,
|
||||
"name": "impersonated_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "impersonated_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_redeemed: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "27cebaa36f0ec873f86cdf8fa994d499db1a5cc84d2c6fceb3587145eb2f9bec"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nWHERE uir.impersonated_user_token = ?\nORDER BY uir.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"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": 2,
|
||||
"name": "impersonator_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "impersonator_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"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": 5,
|
||||
"name": "impersonated_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "impersonated_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_redeemed: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "381e13ab37a2f4ce39bd6db9d90202c301091376108f645a9bb5264a9cd113d7"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n sal.id,\n sal.token as `token: StaffAuditLogToken`,\n sal.audit_action,\n sal.maybe_entity_type,\n sal.maybe_entity_token,\n target_user.username as maybe_target_username,\n target_user.display_name as maybe_target_display_name,\n sal.staff_user_token as `staff_user_token: UserToken`,\n staff_user.username as staff_username,\n staff_user.display_name as staff_display_name,\n sal.staff_ip_address,\n sal.created_at\nFROM staff_audit_logs sal\nLEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token\nLEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token\nORDER BY sal.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token: StaffAuditLogToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "audit_action",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_entity_type",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_entity_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_target_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_target_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "staff_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "staff_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "staff_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "staff_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ",
|
||||
"query": "\nUPDATE users\nSET\n is_banned = ?,\n maybe_mod_comments = ?,\n maybe_mod_user_token = ?,\n version = version + 1\n\nWHERE users.token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eab69a8d679b2dcfb06a9ccc79851f6c693d0c372142ff0e7ded89c95e4bcf44"
|
||||
"hash": "3d746dcb479005f1f4ae5e157a5ace7626eda4849d44c6c2d7079fd346eb4edc"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nWHERE uir.id < ?\nORDER BY uir.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"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": 2,
|
||||
"name": "impersonator_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "impersonator_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"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": 5,
|
||||
"name": "impersonated_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "impersonated_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_redeemed: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5d45897a5d4c326c8d17f105cae81db7f5878c197b55e8ea61c225e739d11234"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n sal.id,\n sal.token as `token: StaffAuditLogToken`,\n sal.audit_action,\n sal.maybe_entity_type,\n sal.maybe_entity_token,\n target_user.username as maybe_target_username,\n target_user.display_name as maybe_target_display_name,\n sal.staff_user_token as `staff_user_token: UserToken`,\n staff_user.username as staff_username,\n staff_user.display_name as staff_display_name,\n sal.staff_ip_address,\n sal.created_at\nFROM staff_audit_logs sal\nLEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token\nLEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token\nWHERE sal.id < ?\nORDER BY sal.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token: StaffAuditLogToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "audit_action",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_entity_type",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_entity_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_target_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_target_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "staff_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "staff_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "staff_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "staff_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846"
|
||||
}
|
||||
+80
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n uir.id,\n uir.impersonator_user_token as `impersonator_user_token: UserToken`,\n impersonator.username as impersonator_username,\n impersonator.display_name as impersonator_display_name,\n uir.impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonated.username as impersonated_username,\n impersonated.display_name as impersonated_display_name,\n uir.is_redeemed as `is_redeemed: bool`,\n (uir.expires_at < NOW()) as `is_expired: bool`,\n uir.expires_at,\n uir.created_at,\n uir.updated_at\nFROM user_impersonation_requests uir\nJOIN users impersonator ON impersonator.token = uir.impersonator_user_token\nJOIN users impersonated ON impersonated.token = uir.impersonated_user_token\nORDER BY uir.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"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": 2,
|
||||
"name": "impersonator_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "impersonator_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"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": 5,
|
||||
"name": "impersonated_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "impersonated_display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_redeemed: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "is_expired: bool",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e72b3dfdd9df4ee10a696faa4dfc3fb8addc9d2b88017f503c339fb616bef6f1"
|
||||
}
|
||||
@@ -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
+20
@@ -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"
|
||||
@@ -7743,6 +7750,17 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
||||
|
||||
[[package]]
|
||||
name = "opaque_cursors"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"magic-crypt",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -11234,6 +11252,7 @@ dependencies = [
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
"enums",
|
||||
"error_utils",
|
||||
"errors",
|
||||
"fal_client",
|
||||
"ffmpeg_utils",
|
||||
@@ -11261,6 +11280,7 @@ dependencies = [
|
||||
"mimetypes",
|
||||
"mysql_queries",
|
||||
"once_cell",
|
||||
"opaque_cursors",
|
||||
"pager",
|
||||
"password",
|
||||
"primitives",
|
||||
|
||||
@@ -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",
|
||||
@@ -72,6 +73,7 @@ members = [
|
||||
"crates/lib/http_headers",
|
||||
"crates/lib/jobs_common",
|
||||
"crates/lib/jwt_light",
|
||||
"crates/lib/opaque_cursors",
|
||||
"crates/lib/jwt_signer",
|
||||
"crates/lib/logging",
|
||||
"crates/lib/markdown",
|
||||
@@ -236,6 +238,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" }
|
||||
@@ -250,6 +253,7 @@ images = { path = "crates/lib/files/images" }
|
||||
jobs_common = { path = "crates/lib/jobs_common" }
|
||||
jwt_light = { path = "crates/lib/jwt_light" }
|
||||
jwt_signer = { path = "crates/lib/jwt_signer" }
|
||||
opaque_cursors = { path = "crates/lib/opaque_cursors" }
|
||||
memory_caching = { path = "crates/lib/caching/memory_caching" }
|
||||
memory_store = { path = "crates/lib/caching/memory_store" }
|
||||
midjourney_client = { path = "crates/api_clients/midjourney_client" }
|
||||
|
||||
@@ -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;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE user_sessions
|
||||
DROP COLUMN maybe_impersonation_user_token;
|
||||
+7
@@ -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;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Recreate with the original signed BIGINT id.
|
||||
CREATE TABLE user_impersonation_requests (
|
||||
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
impersonated_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
impersonator_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
user_impersonation_token VARCHAR(32) NOT NULL,
|
||||
|
||||
is_redeemed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
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,
|
||||
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
|
||||
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;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_impersonation_requests;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_impersonation_requests;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE user_impersonation_requests (
|
||||
|
||||
-- Not used for anything except replication.
|
||||
id BIGINT(20) UNSIGNED 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,31 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Recreate with the original signed BIGINT id.
|
||||
CREATE TABLE staff_audit_logs (
|
||||
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
audit_action VARCHAR(32) NOT NULL,
|
||||
|
||||
maybe_entity_type VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
maybe_entity_token VARCHAR(32) NOT NULL,
|
||||
|
||||
staff_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
staff_ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
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,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS staff_audit_logs;
|
||||
@@ -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) UNSIGNED 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,38 @@
|
||||
# SQL Migrations
|
||||
|
||||
Incremental schema changes applied in order by timestamp prefix.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
Migration directories follow the format:
|
||||
|
||||
```
|
||||
YYYY-MM-DD-HHMMSS-NNNN_description_goes_here/
|
||||
up.sql
|
||||
down.sql
|
||||
```
|
||||
|
||||
These are created with the `diesel` tool, and in particular:
|
||||
|
||||
> `diesel migration generate description_goes_here`
|
||||
|
||||
- `NNNN` is a zero-padded sequence number for ordering multiple migrations on the same timestamp.
|
||||
- `up.sql` applies the migration forward.
|
||||
- `down.sql` reverses it.
|
||||
|
||||
## Table Conventions
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Every `up.sql` must have a corresponding `down.sql` that cleanly reverses the migration.
|
||||
- For `CREATE TABLE` migrations, the `down.sql` is typically `DROP TABLE IF EXISTS table_name;`.
|
||||
- For `ALTER TABLE` migrations, the `down.sql` reverses the alteration (e.g. `DROP COLUMN`).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Squashed / Materialized Schemas
|
||||
|
||||
Fully materialized table definitions representing the current state of each table
|
||||
after all migrations have been applied. These are not executed — they exist as
|
||||
human-readable documentation of the current schema.
|
||||
|
||||
## When to Update
|
||||
|
||||
Update the corresponding squashed file whenever a migration changes a table's schema
|
||||
(adding columns, changing types, dropping and recreating, etc.).
|
||||
|
||||
## Table Conventions
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
Files are named after their table (e.g. `users.sql`, `media_files.sql`).
|
||||
Some tables are grouped into subdirectories (e.g. `user_tables/`).
|
||||
@@ -0,0 +1,39 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE staff_audit_logs (
|
||||
|
||||
-- Not used for anything except replication.
|
||||
id BIGINT(20) UNSIGNED 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) UNSIGNED 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;
|
||||
+11
@@ -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
|
||||
@@ -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
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "opaque_cursors"
|
||||
edition = "2021"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "opaque_cursors"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
magic-crypt = "3.1.13"
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod v1;
|
||||
pub mod v2;
|
||||
@@ -0,0 +1 @@
|
||||
// Reserved for migrating the legacy SortKeyCrypto / SortId from storyteller-web.
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod opaque_cursor_encoder_v2;
|
||||
pub mod opaque_cursor_error_v2;
|
||||
pub mod opaque_cursor_v2;
|
||||
@@ -0,0 +1,243 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use base64::alphabet::{Alphabet, URL_SAFE};
|
||||
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
|
||||
use base64::Engine;
|
||||
use magic_crypt::generic_array::typenum::U256;
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptTrait, new_magic_crypt};
|
||||
|
||||
use crate::v2::opaque_cursor_error_v2::OpaqueCursorErrorV2;
|
||||
use crate::v2::opaque_cursor_v2::OpaqueCursorV2;
|
||||
|
||||
/// Encrypts and decrypts opaque cursor payloads so that internal database
|
||||
/// IDs are never leaked to the frontend.
|
||||
#[derive(Clone)]
|
||||
pub struct OpaqueCursorEncoderV2 {
|
||||
crypt: MagicCrypt256,
|
||||
alphabet: Alphabet,
|
||||
base64_config: GeneralPurposeConfig,
|
||||
}
|
||||
|
||||
impl OpaqueCursorEncoderV2 {
|
||||
pub fn new(secret: &str) -> Self {
|
||||
let base64_config = GeneralPurposeConfig::new()
|
||||
.with_encode_padding(false)
|
||||
.with_decode_allow_trailing_bits(true)
|
||||
.with_decode_padding_mode(DecodePaddingMode::Indifferent);
|
||||
|
||||
Self {
|
||||
crypt: new_magic_crypt!(secret, 256),
|
||||
alphabet: URL_SAFE,
|
||||
base64_config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a last-id cursor into an opaque string.
|
||||
pub fn encode_last_id_cursor(&self, name: &str, id: u64) -> Result<String, OpaqueCursorErrorV2> {
|
||||
let cursor = OpaqueCursorV2 {
|
||||
name: Some(name.to_string()),
|
||||
last_id: Some(id),
|
||||
};
|
||||
self.encode_cursor(&cursor)
|
||||
}
|
||||
|
||||
/// Encode an arbitrary cursor payload into an opaque string.
|
||||
pub fn encode_cursor(&self, cursor: &OpaqueCursorV2) -> Result<String, OpaqueCursorErrorV2> {
|
||||
let json = serde_json::to_string(cursor)?;
|
||||
|
||||
let engine = GeneralPurpose::new(&self.alphabet, self.base64_config);
|
||||
|
||||
let mut reader = Cursor::new(json);
|
||||
let mut writer = Vec::new();
|
||||
|
||||
self.crypt.encrypt_reader_to_writer2::<U256>(&mut reader, &mut writer)?;
|
||||
|
||||
Ok(engine.encode(&writer))
|
||||
}
|
||||
|
||||
/// Decode an opaque cursor string and verify the name matches `expected_name`.
|
||||
pub fn decode_cursor_expecting_name(
|
||||
&self,
|
||||
expected_name: &str,
|
||||
cursor: &str,
|
||||
) -> Result<OpaqueCursorV2, OpaqueCursorErrorV2> {
|
||||
let decoded = self.decode_cursor(cursor)?;
|
||||
|
||||
match &decoded.name {
|
||||
None => Err(OpaqueCursorErrorV2::DecodedNameNotPresent),
|
||||
Some(actual) if actual != expected_name => {
|
||||
Err(OpaqueCursorErrorV2::DecodedNameMismatch {
|
||||
expected: expected_name.to_string(),
|
||||
actual: actual.clone(),
|
||||
})
|
||||
}
|
||||
_ => Ok(decoded),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an opaque cursor string back into a cursor payload.
|
||||
pub fn decode_cursor(&self, cursor: &str) -> Result<OpaqueCursorV2, OpaqueCursorErrorV2> {
|
||||
let engine = GeneralPurpose::new(&self.alphabet, self.base64_config);
|
||||
|
||||
let decoded_bytes = engine.decode(cursor)?;
|
||||
let decrypted_bytes = self.crypt.decrypt_bytes_to_bytes(&decoded_bytes)?;
|
||||
|
||||
let json = String::from_utf8(decrypted_bytes)?;
|
||||
let cursor = serde_json::from_str::<OpaqueCursorV2>(&json)?;
|
||||
|
||||
Ok(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SECRET: &str = "test_secret_key_for_opaque_cursors";
|
||||
|
||||
#[test]
|
||||
fn roundtrip_last_id() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("test", 42).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("test".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_none_fields() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let cursor = OpaqueCursorV2 { name: None, last_id: None };
|
||||
let encoded = encoder.encode_cursor(&cursor).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, None);
|
||||
assert_eq!(decoded.last_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_zero() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("zero", 0).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("zero".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_large_id() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("big", u64::MAX).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.last_id, Some(u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_many_ids() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
for id in [0, 1, 100, 999, 100_000, 1_000_000_000, u64::MAX - 1] {
|
||||
let encoded = encoder.encode_last_id_cursor("batch", id).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("batch".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(id), "roundtrip failed for id={}", id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_preserved_in_roundtrip() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("media_files_list", 999).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("media_files_list".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_cursor() {
|
||||
let encoder = OpaqueCursorEncoderV2::new("stable_secret");
|
||||
let encoded = encoder.encode_last_id_cursor("media_files_list", 123456).unwrap();
|
||||
assert_eq!(encoded, "0-8bNhoOVA37eZAKwVQ0B8kWq-HLs0x0gQc51TJXjw2hWJ7PNfVqas7WvRnIK80O");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_secrets_produce_different_output() {
|
||||
let encoder_a = OpaqueCursorEncoderV2::new("secret_a");
|
||||
let encoder_b = OpaqueCursorEncoderV2::new("secret_b");
|
||||
|
||||
let encoded_a = encoder_a.encode_last_id_cursor("test", 42).unwrap();
|
||||
let encoded_b = encoder_b.encode_last_id_cursor("test", 42).unwrap();
|
||||
|
||||
assert_ne!(encoded_a, encoded_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_secret_fails_to_decode() {
|
||||
let encoder_a = OpaqueCursorEncoderV2::new("secret_a");
|
||||
let encoder_b = OpaqueCursorEncoderV2::new("secret_b");
|
||||
|
||||
let encoded = encoder_a.encode_last_id_cursor("test", 42).unwrap();
|
||||
let result = encoder_b.decode_cursor(&encoded);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_base64_fails() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let result = encoder.decode_cursor("!!!not-valid-base64!!!");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_fails() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let result = encoder.decode_cursor("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_cursor_is_url_safe() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
for id in [0, 1, 42, 999_999_999] {
|
||||
let encoded = encoder.encode_last_id_cursor("safe", id).unwrap();
|
||||
assert!(
|
||||
encoded.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
||||
"non-URL-safe character in cursor: {:?}",
|
||||
encoded,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_expecting_name_matches() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("my_list", 100).unwrap();
|
||||
let decoded = encoder.decode_cursor_expecting_name("my_list", &encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("my_list".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_expecting_name_mismatch() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("wrong_name", 100).unwrap();
|
||||
let result = encoder.decode_cursor_expecting_name("expected_name", &encoded);
|
||||
assert!(matches!(result, Err(OpaqueCursorErrorV2::DecodedNameMismatch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_expecting_name_not_present() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let cursor = OpaqueCursorV2 { name: None, last_id: Some(42) };
|
||||
let encoded = encoder.encode_cursor(&cursor).unwrap();
|
||||
let result = encoder.decode_cursor_expecting_name("anything", &encoded);
|
||||
assert!(matches!(result, Err(OpaqueCursorErrorV2::DecodedNameNotPresent)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_output() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let a = encoder.encode_last_id_cursor("det", 42).unwrap();
|
||||
let b = encoder.encode_last_id_cursor("det", 42).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::fmt;
|
||||
use std::string::FromUtf8Error;
|
||||
|
||||
use magic_crypt::MagicCryptError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OpaqueCursorErrorV2 {
|
||||
SerdeError(serde_json::Error),
|
||||
MagicCryptError(MagicCryptError),
|
||||
Base64DecodeError(base64::DecodeError),
|
||||
Utf8Error(FromUtf8Error),
|
||||
/// The decoded cursor did not contain a name at all.
|
||||
DecodedNameNotPresent,
|
||||
/// The decoded cursor's name did not match the expected name.
|
||||
DecodedNameMismatch { expected: String, actual: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for OpaqueCursorErrorV2 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OpaqueCursorErrorV2 {}
|
||||
|
||||
impl From<serde_json::Error> for OpaqueCursorErrorV2 {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::SerdeError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MagicCryptError> for OpaqueCursorErrorV2 {
|
||||
fn from(err: MagicCryptError) -> Self {
|
||||
Self::MagicCryptError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for OpaqueCursorErrorV2 {
|
||||
fn from(err: base64::DecodeError) -> Self {
|
||||
Self::Base64DecodeError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FromUtf8Error> for OpaqueCursorErrorV2 {
|
||||
fn from(err: FromUtf8Error) -> Self {
|
||||
Self::Utf8Error(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// An opaque cursor payload that encodes pagination state.
|
||||
///
|
||||
/// This gets encrypted and sent to the frontend as an opaque handle.
|
||||
/// The frontend cannot read or forge these cursors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpaqueCursorV2 {
|
||||
/// Name for the cursor type.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "n")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// The id of the last ordered element we fetched, so the next page
|
||||
/// will start after (or before) this id depending on sort direction.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "l")]
|
||||
pub last_id: Option<u64>,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# mysql_queries
|
||||
|
||||
SQLx-based MySQL queries for the storyteller-web backend and background jobs.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
SQLX_OFFLINE=true cargo check -p mysql_queries
|
||||
```
|
||||
|
||||
The `SQLX_OFFLINE=true` env var is **required** for compilation. This crate uses the sqlx
|
||||
offline cache (`.sqlx/` directory at the workspace root) to validate queries at compile time
|
||||
without a live database connection.
|
||||
|
||||
## Query Style
|
||||
|
||||
### Prefer `sqlx::query!` and `sqlx::query_as!` macros
|
||||
|
||||
Always use the compile-time checked `sqlx::query!` and `sqlx::query_as!` macros over the
|
||||
runtime `sqlx::query()` and `sqlx::query_as()` functions. The macros validate SQL syntax,
|
||||
column names, and parameter types against the offline cache at compile time.
|
||||
|
||||
```rust
|
||||
// PREFERRED: compile-time checked
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO my_table SET token = ?, name = ?"#,
|
||||
token,
|
||||
name,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
// AVOID: runtime only, no compile-time checking
|
||||
sqlx::query(r#"INSERT INTO my_table SET token = ?, name = ?"#)
|
||||
.bind(token)
|
||||
.bind(name)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
```
|
||||
|
||||
Use runtime `sqlx::query()` only when:
|
||||
- The query involves a table not yet in the offline cache and you cannot run `cargo sqlx prepare`
|
||||
- The query is dynamically constructed (rare)
|
||||
- SELECT queries for new tables where column type metadata isn't cached yet
|
||||
|
||||
### Offline Cache
|
||||
|
||||
When adding new queries for **existing tables**, the `.sqlx/` cache should already have the
|
||||
schema metadata. For **new tables**, you may need to:
|
||||
|
||||
1. Run the migration against a real database
|
||||
2. Run `cargo sqlx prepare` to regenerate the cache
|
||||
3. Or manually create cache entries for simple INSERT/UPDATE queries (they only need parameter count)
|
||||
|
||||
### Table Conventions
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
|
||||
### Error Types
|
||||
|
||||
New queries should return `Result<T, sqlx::Error>` instead of the legacy `AnyhowResult<T>`.
|
||||
The `AnyhowResult` pattern is being phased out.
|
||||
|
||||
### Transaction Support
|
||||
|
||||
Use the executor-generic `Args` pattern for queries that need to work inside transactions:
|
||||
|
||||
```rust
|
||||
pub struct MyQueryArgs<'a, 'c: 'a, E>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
pub field: &'a str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
```
|
||||
|
||||
This allows the same query function to accept both `&MySqlPool` and `&mut Transaction`.
|
||||
@@ -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;
|
||||
|
||||
+54
@@ -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)
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use tokens::tokens::staff_audit_logs::StaffAuditLogToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct StaffAuditLogListItem {
|
||||
pub id: u64,
|
||||
pub token: StaffAuditLogToken,
|
||||
pub audit_action: String,
|
||||
pub maybe_entity_type: Option<String>,
|
||||
pub maybe_entity_token: Option<String>,
|
||||
pub maybe_target_username: Option<String>,
|
||||
pub maybe_target_display_name: Option<String>,
|
||||
pub staff_user_token: Option<UserToken>,
|
||||
pub staff_username: Option<String>,
|
||||
pub staff_display_name: Option<String>,
|
||||
pub staff_ip_address: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListStaffAuditLogsArgs<'a> {
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_staff_audit_logs(
|
||||
args: ListStaffAuditLogsArgs<'_>,
|
||||
) -> Result<Vec<StaffAuditLogListItem>, sqlx::Error> {
|
||||
|
||||
let limit = args.limit as i64;
|
||||
|
||||
let items = match args.maybe_cursor_id {
|
||||
Some(cursor_id) => {
|
||||
sqlx::query_as!(
|
||||
StaffAuditLogListItem,
|
||||
r#"
|
||||
SELECT
|
||||
sal.id,
|
||||
sal.token as `token: StaffAuditLogToken`,
|
||||
sal.audit_action,
|
||||
sal.maybe_entity_type,
|
||||
sal.maybe_entity_token,
|
||||
target_user.username as maybe_target_username,
|
||||
target_user.display_name as maybe_target_display_name,
|
||||
sal.staff_user_token as `staff_user_token: UserToken`,
|
||||
staff_user.username as staff_username,
|
||||
staff_user.display_name as staff_display_name,
|
||||
sal.staff_ip_address,
|
||||
sal.created_at
|
||||
FROM staff_audit_logs sal
|
||||
LEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token
|
||||
LEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token
|
||||
WHERE sal.id < ?
|
||||
ORDER BY sal.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
cursor_id as u64,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_pool)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as!(
|
||||
StaffAuditLogListItem,
|
||||
r#"
|
||||
SELECT
|
||||
sal.id,
|
||||
sal.token as `token: StaffAuditLogToken`,
|
||||
sal.audit_action,
|
||||
sal.maybe_entity_type,
|
||||
sal.maybe_entity_token,
|
||||
target_user.username as maybe_target_username,
|
||||
target_user.display_name as maybe_target_display_name,
|
||||
sal.staff_user_token as `staff_user_token: UserToken`,
|
||||
staff_user.username as staff_username,
|
||||
staff_user.display_name as staff_display_name,
|
||||
sal.staff_ip_address,
|
||||
sal.created_at
|
||||
FROM staff_audit_logs sal
|
||||
LEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token
|
||||
LEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token
|
||||
ORDER BY sal.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod insert_staff_audit_log;
|
||||
pub mod list_staff_audit_logs;
|
||||
+51
@@ -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)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct UserImpersonationRequestListItem {
|
||||
pub id: u64,
|
||||
pub impersonator_user_token: UserToken,
|
||||
pub impersonator_username: String,
|
||||
pub impersonator_display_name: String,
|
||||
pub impersonated_user_token: UserToken,
|
||||
pub impersonated_username: String,
|
||||
pub impersonated_display_name: String,
|
||||
pub is_redeemed: bool,
|
||||
pub is_expired: bool,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListUserImpersonationRequestsArgs<'a> {
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_user_impersonation_requests(
|
||||
args: ListUserImpersonationRequestsArgs<'_>,
|
||||
) -> Result<Vec<UserImpersonationRequestListItem>, sqlx::Error> {
|
||||
|
||||
let limit = args.limit as i64;
|
||||
|
||||
let items = match args.maybe_cursor_id {
|
||||
Some(cursor_id) => {
|
||||
sqlx::query_as!(
|
||||
UserImpersonationRequestListItem,
|
||||
r#"
|
||||
SELECT
|
||||
uir.id,
|
||||
uir.impersonator_user_token as `impersonator_user_token: UserToken`,
|
||||
impersonator.username as impersonator_username,
|
||||
impersonator.display_name as impersonator_display_name,
|
||||
uir.impersonated_user_token as `impersonated_user_token: UserToken`,
|
||||
impersonated.username as impersonated_username,
|
||||
impersonated.display_name as impersonated_display_name,
|
||||
uir.is_redeemed as `is_redeemed: bool`,
|
||||
(uir.expires_at < NOW()) as `is_expired: bool`,
|
||||
uir.expires_at,
|
||||
uir.created_at,
|
||||
uir.updated_at
|
||||
FROM user_impersonation_requests uir
|
||||
JOIN users impersonator ON impersonator.token = uir.impersonator_user_token
|
||||
JOIN users impersonated ON impersonated.token = uir.impersonated_user_token
|
||||
WHERE uir.id < ?
|
||||
ORDER BY uir.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
cursor_id as u64,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_pool)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as!(
|
||||
UserImpersonationRequestListItem,
|
||||
r#"
|
||||
SELECT
|
||||
uir.id,
|
||||
uir.impersonator_user_token as `impersonator_user_token: UserToken`,
|
||||
impersonator.username as impersonator_username,
|
||||
impersonator.display_name as impersonator_display_name,
|
||||
uir.impersonated_user_token as `impersonated_user_token: UserToken`,
|
||||
impersonated.username as impersonated_username,
|
||||
impersonated.display_name as impersonated_display_name,
|
||||
uir.is_redeemed as `is_redeemed: bool`,
|
||||
(uir.expires_at < NOW()) as `is_expired: bool`,
|
||||
uir.expires_at,
|
||||
uir.created_at,
|
||||
uir.updated_at
|
||||
FROM user_impersonation_requests uir
|
||||
JOIN users impersonator ON impersonator.token = uir.impersonator_user_token
|
||||
JOIN users impersonated ON impersonated.token = uir.impersonated_user_token
|
||||
ORDER BY uir.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct UserImpersonationRequestListItem {
|
||||
pub id: u64,
|
||||
pub impersonator_user_token: UserToken,
|
||||
pub impersonator_username: String,
|
||||
pub impersonator_display_name: String,
|
||||
pub impersonated_user_token: UserToken,
|
||||
pub impersonated_username: String,
|
||||
pub impersonated_display_name: String,
|
||||
pub is_redeemed: bool,
|
||||
pub is_expired: bool,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListUserImpersonationRequestsArgs<'a> {
|
||||
pub user_token: &'a str,
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_user_impersonation_requests_for_user(
|
||||
args: ListUserImpersonationRequestsArgs<'_>,
|
||||
) -> Result<Vec<UserImpersonationRequestListItem>, sqlx::Error> {
|
||||
|
||||
let limit = args.limit as i64;
|
||||
|
||||
let items = match args.maybe_cursor_id {
|
||||
Some(cursor_id) => {
|
||||
sqlx::query_as!(
|
||||
UserImpersonationRequestListItem,
|
||||
r#"
|
||||
SELECT
|
||||
uir.id,
|
||||
uir.impersonator_user_token as `impersonator_user_token: UserToken`,
|
||||
impersonator.username as impersonator_username,
|
||||
impersonator.display_name as impersonator_display_name,
|
||||
uir.impersonated_user_token as `impersonated_user_token: UserToken`,
|
||||
impersonated.username as impersonated_username,
|
||||
impersonated.display_name as impersonated_display_name,
|
||||
uir.is_redeemed as `is_redeemed: bool`,
|
||||
(uir.expires_at < NOW()) as `is_expired: bool`,
|
||||
uir.expires_at,
|
||||
uir.created_at,
|
||||
uir.updated_at
|
||||
FROM user_impersonation_requests uir
|
||||
JOIN users impersonator ON impersonator.token = uir.impersonator_user_token
|
||||
JOIN users impersonated ON impersonated.token = uir.impersonated_user_token
|
||||
WHERE uir.impersonated_user_token = ?
|
||||
AND uir.id < ?
|
||||
ORDER BY uir.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.user_token,
|
||||
cursor_id as u64,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_pool)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as!(
|
||||
UserImpersonationRequestListItem,
|
||||
r#"
|
||||
SELECT
|
||||
uir.id,
|
||||
uir.impersonator_user_token as `impersonator_user_token: UserToken`,
|
||||
impersonator.username as impersonator_username,
|
||||
impersonator.display_name as impersonator_display_name,
|
||||
uir.impersonated_user_token as `impersonated_user_token: UserToken`,
|
||||
impersonated.username as impersonated_username,
|
||||
impersonated.display_name as impersonated_display_name,
|
||||
uir.is_redeemed as `is_redeemed: bool`,
|
||||
(uir.expires_at < NOW()) as `is_expired: bool`,
|
||||
uir.expires_at,
|
||||
uir.created_at,
|
||||
uir.updated_at
|
||||
FROM user_impersonation_requests uir
|
||||
JOIN users impersonator ON impersonator.token = uir.impersonator_user_token
|
||||
JOIN users impersonated ON impersonated.token = uir.impersonated_user_token
|
||||
WHERE uir.impersonated_user_token = ?
|
||||
ORDER BY uir.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.user_token,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
+35
@@ -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,5 @@
|
||||
pub mod insert_user_impersonation_request;
|
||||
pub mod list_user_impersonation_requests;
|
||||
pub mod list_user_impersonation_requests_for_user;
|
||||
pub mod lookup_user_impersonation_request;
|
||||
pub mod mark_impersonation_token_as_redeemed;
|
||||
+24
-20
@@ -1,20 +1,27 @@
|
||||
use anyhow::anyhow;
|
||||
use sqlx::MySqlPool;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct SetUserBanStatsArgs<'a> {
|
||||
pub struct SetUserBanStatusArgs<'a, 'c: 'a, E>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
pub subject_user_token: &'a UserToken,
|
||||
pub is_banned: bool,
|
||||
pub mod_user_token: &'a UserToken,
|
||||
pub maybe_mod_comments: Option<&'a str>,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn set_user_ban_status(args: SetUserBanStatsArgs<'_>) -> AnyhowResult<()> {
|
||||
let query_result = sqlx::query!(
|
||||
r#"
|
||||
pub async fn set_user_ban_status<'a, 'c, E>(
|
||||
args: SetUserBanStatusArgs<'a, 'c, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET
|
||||
is_banned = ?,
|
||||
@@ -24,17 +31,14 @@ SET
|
||||
|
||||
WHERE users.token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.is_banned,
|
||||
args.maybe_mod_comments,
|
||||
args.mod_user_token,
|
||||
args.subject_user_token,
|
||||
)
|
||||
.execute(args.mysql_pool)
|
||||
.await;
|
||||
"#,
|
||||
args.is_banned,
|
||||
args.maybe_mod_comments,
|
||||
args.mod_user_token,
|
||||
args.subject_user_token,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
match query_result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(anyhow!("error with query: {:?}", err)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+53
@@ -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,135 @@
|
||||
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,
|
||||
|
||||
/// Staff banned a user.
|
||||
#[serde(rename = "ban_user")]
|
||||
BanUser,
|
||||
|
||||
/// Staff unbanned a user.
|
||||
#[serde(rename = "unban_user")]
|
||||
UnbanUser,
|
||||
}
|
||||
|
||||
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",
|
||||
Self::BanUser => "ban_user",
|
||||
Self::UnbanUser => "unban_user",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"impersonate_user_request" => Ok(Self::ImpersonateUserRequest),
|
||||
"impersonate_user_redeem" => Ok(Self::ImpersonateUserRedeem),
|
||||
"ban_user" => Ok(Self::BanUser),
|
||||
"unban_user" => Ok(Self::UnbanUser),
|
||||
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::ImpersonateUserRequest,
|
||||
Self::ImpersonateUserRedeem,
|
||||
Self::BanUser,
|
||||
Self::UnbanUser,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
assert_serialization(StaffAuditAction::BanUser, "ban_user");
|
||||
assert_serialization(StaffAuditAction::UnbanUser, "unban_user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(StaffAuditAction::ImpersonateUserRequest.to_str(), "impersonate_user_request");
|
||||
assert_eq!(StaffAuditAction::ImpersonateUserRedeem.to_str(), "impersonate_user_redeem");
|
||||
assert_eq!(StaffAuditAction::BanUser.to_str(), "ban_user");
|
||||
assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user");
|
||||
}
|
||||
|
||||
#[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_eq!(StaffAuditAction::from_str("ban_user").unwrap(), StaffAuditAction::BanUser);
|
||||
assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser);
|
||||
assert!(StaffAuditAction::from_str("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
const EXPECTED_COUNT: usize = 4;
|
||||
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
|
||||
@@ -64,6 +65,7 @@ memory_caching = { path = "../../../lib/caching/memory_caching" }
|
||||
migration = { path = "../../../schema/database/migration" }
|
||||
mimetypes.workspace = true
|
||||
mysql_queries.workspace = true
|
||||
opaque_cursors.workspace = true
|
||||
pager.workspace = true
|
||||
password = { path = "../../../lib/password" }
|
||||
rootly_client.workspace = true
|
||||
|
||||
@@ -86,6 +86,11 @@ 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::staff_audit_logs::moderator_list_staff_audit_logs_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_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 +512,11 @@ 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::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler,
|
||||
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_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,
|
||||
// Credits
|
||||
crate::http_server::endpoints::credits::get_session_credits_handler::get_session_credits_handler,
|
||||
// Subscriptions
|
||||
@@ -592,6 +602,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
WeightsDefaultCoverInfo,
|
||||
|
||||
// Endpoint API types
|
||||
AllImpersonationRequestResponse,
|
||||
AppStateError,
|
||||
AppStateLegacyPermissionFlags,
|
||||
AppStatePermissions,
|
||||
@@ -885,6 +896,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
ListFeaturedWeightsError,
|
||||
ListFeaturedWeightsQueryParams,
|
||||
ListFeaturedWeightsSuccessResponse,
|
||||
ListAllImpersonationRequestsQueryParams,
|
||||
ListAllImpersonationRequestsSuccessResponse,
|
||||
ListImpersonationRequestsPathInfo,
|
||||
ListStaffAuditLogsQueryParams,
|
||||
ListStaffAuditLogsSuccessResponse,
|
||||
ListImpersonationRequestsQueryParams,
|
||||
ListUserImpersonationRequestsSuccessResponse,
|
||||
ListMediaFilesByBatchPathInfo,
|
||||
ListMediaFilesByBatchSuccessResponse,
|
||||
ListMediaFilesForUserPathInfo,
|
||||
@@ -931,6 +949,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
MediaFilesByBatchListItem,
|
||||
ModelWeightForList,
|
||||
ModelWeightSearchResult,
|
||||
ModerationBanUserRequest,
|
||||
ModerationBanUserSuccessResponse,
|
||||
ModerationImpersonateRequest,
|
||||
ModerationImpersonateSuccessResponse,
|
||||
ModernInferenceQueueStats,
|
||||
PinnedMediaFile,
|
||||
PinnedModelWeightForList,
|
||||
@@ -946,6 +968,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
RenameMediaFileRequest,
|
||||
RequestDetailsResponse,
|
||||
ResultDetailsResponse,
|
||||
StaffAuditLogResponse,
|
||||
SearchFeaturedMediaFileListItem,
|
||||
SearchFeaturedMediaFilesError,
|
||||
SearchFeaturedMediaFilesQueryParams,
|
||||
@@ -991,6 +1014,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
UploadEngineAssetMediaSuccessResponse,
|
||||
UploadImageMediaFileForm,
|
||||
UploadImageMediaFileSuccessResponse,
|
||||
UserImpersonationRequestResponse,
|
||||
UploadMediaSuccessResponse,
|
||||
UploadNewEngineAssetFileForm,
|
||||
UploadNewEngineAssetSuccessResponse,
|
||||
|
||||
@@ -2,8 +2,10 @@ pub mod alerts;
|
||||
pub mod info;
|
||||
pub mod ip_bans;
|
||||
pub mod jobs;
|
||||
pub mod staff_audit_logs;
|
||||
pub mod user;
|
||||
pub mod user_bans;
|
||||
pub mod user_feature_flags;
|
||||
pub mod user_sessions;
|
||||
pub mod wallet_ledger_entries;
|
||||
pub mod wallets;
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_list_staff_audit_logs_handler;
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use mysql_queries::queries::staff_audit_logs::list_staff_audit_logs::{
|
||||
list_staff_audit_logs, ListStaffAuditLogsArgs,
|
||||
};
|
||||
use tokens::tokens::staff_audit_logs::StaffAuditLogToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
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;
|
||||
|
||||
const CURSOR_NAME: &str = "modstfaud";
|
||||
const DEFAULT_LIMIT: u32 = 50;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
// --- Request ---
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListStaffAuditLogsQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListStaffAuditLogsSuccessResponse {
|
||||
pub success: bool,
|
||||
pub audit_logs: Vec<StaffAuditLogResponse>,
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct StaffAuditLogResponse {
|
||||
pub token: StaffAuditLogToken,
|
||||
pub audit_action: String,
|
||||
pub maybe_entity_type: Option<String>,
|
||||
pub maybe_entity_token: Option<String>,
|
||||
pub maybe_target_username: Option<String>,
|
||||
pub maybe_target_display_name: Option<String>,
|
||||
pub staff_user_token: Option<UserToken>,
|
||||
pub staff_username: Option<String>,
|
||||
pub staff_display_name: Option<String>,
|
||||
pub staff_ip_address: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
/// List all staff audit logs. Moderators only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/staff_audit_logs/list",
|
||||
params(
|
||||
ListStaffAuditLogsQueryParams,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ListStaffAuditLogsSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_list_staff_audit_logs_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListStaffAuditLogsQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListStaffAuditLogsSuccessResponse>, AdvancedCommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(
|
||||
&http_request,
|
||||
&server_state,
|
||||
UseDatabase::GrabNewConnection,
|
||||
).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
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);
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
}
|
||||
};
|
||||
|
||||
let records = list_staff_audit_logs(
|
||||
ListStaffAuditLogsArgs {
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
},
|
||||
).await.map_err(|err| {
|
||||
warn!("Failed to list staff audit logs: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = records.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);
|
||||
AdvancedCommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
|
||||
let audit_logs = records.into_iter().map(|r| {
|
||||
StaffAuditLogResponse {
|
||||
token: r.token,
|
||||
audit_action: r.audit_action,
|
||||
maybe_entity_type: r.maybe_entity_type,
|
||||
maybe_entity_token: r.maybe_entity_token,
|
||||
maybe_target_username: r.maybe_target_username,
|
||||
maybe_target_display_name: r.maybe_target_display_name,
|
||||
staff_user_token: r.staff_user_token,
|
||||
staff_username: r.staff_username,
|
||||
staff_display_name: r.staff_display_name,
|
||||
staff_ip_address: r.staff_ip_address,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(Json(ListStaffAuditLogsSuccessResponse {
|
||||
success: true,
|
||||
audit_logs,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use log::warn;
|
||||
|
||||
use mysql_queries::queries::users::user::update::set_user_ban_status::{set_user_ban_status, SetUserBanStatsArgs};
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
|
||||
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanUserRequest {
|
||||
pub username: String,
|
||||
pub mod_notes: String,
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BanUserSuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct BanUserErrorResponse {
|
||||
pub success: bool,
|
||||
pub error_type: BanUserErrorType,
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize)]
|
||||
pub enum BanUserErrorType {
|
||||
ServerError,
|
||||
Unauthorized,
|
||||
UserNotFound,
|
||||
}
|
||||
|
||||
impl BanUserErrorResponse {
|
||||
fn unauthorized() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error_type: BanUserErrorType::Unauthorized,
|
||||
error_message: "unauthorized".to_string()
|
||||
}
|
||||
}
|
||||
fn server_error() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error_type: BanUserErrorType::ServerError,
|
||||
error_message: "server error".to_string()
|
||||
}
|
||||
}
|
||||
fn not_found() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error_type: BanUserErrorType::UserNotFound,
|
||||
error_message: "user not found".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for BanUserErrorResponse {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.error_type {
|
||||
BanUserErrorType::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
BanUserErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
BanUserErrorType::UserNotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
serialize_as_json_error(self)
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Not using DeriveMore since Clion doesn't understand it.
|
||||
impl fmt::Display for BanUserErrorResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self.error_type)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ban_user_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<BanUserRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, BanUserErrorResponse> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
BanUserErrorResponse::server_error()
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(BanUserErrorResponse::unauthorized());
|
||||
}
|
||||
};
|
||||
|
||||
if !user_session.can_ban_users {
|
||||
warn!("user is not allowed to add bans: {:?}", user_session.user_token);
|
||||
return Err(BanUserErrorResponse::unauthorized());
|
||||
}
|
||||
|
||||
let username_lower = request.username.to_lowercase();
|
||||
|
||||
let user_lookup_result = get_user_profile_by_username(
|
||||
&username_lower, &server_state.mysql_pool).await;
|
||||
|
||||
let user_profile = match user_lookup_result {
|
||||
Ok(Some(result)) => result,
|
||||
Ok(None) => return Err(BanUserErrorResponse::not_found()),
|
||||
Err(err) => {
|
||||
warn!("lookup error: {:?}", err);
|
||||
return Err(BanUserErrorResponse::server_error());
|
||||
}
|
||||
};
|
||||
|
||||
set_user_ban_status(SetUserBanStatsArgs {
|
||||
subject_user_token: &user_profile.user_token,
|
||||
is_banned: request.is_banned,
|
||||
mod_user_token: &user_session.user_token,
|
||||
maybe_mod_comments: Some(&request.mod_notes),
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
}).await
|
||||
.map_err(|err| {
|
||||
warn!("Add IP ban DB error: {:?}", err);
|
||||
BanUserErrorResponse::server_error()
|
||||
})?;
|
||||
|
||||
Ok(simple_json_success())
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pub mod ban_user;
|
||||
pub mod moderation_ban_user_handler;
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
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::update::set_user_ban_status::{
|
||||
set_user_ban_status, SetUserBanStatusArgs,
|
||||
};
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
|
||||
|
||||
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 ModerationBanUserRequest {
|
||||
pub username: String,
|
||||
pub mod_notes: String,
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModerationBanUserSuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
/// Ban or unban a user. Moderators only.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Moderation",
|
||||
path = "/moderation/user_bans/manage_ban",
|
||||
request_body = ModerationBanUserRequest,
|
||||
responses(
|
||||
(status = 200, description = "User ban status updated", body = ModerationBanUserSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn moderation_ban_user_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<ModerationBanUserRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModerationBanUserSuccessResponse>, AdvancedCommonWebError> {
|
||||
|
||||
// 1. Require moderator with ban permissions.
|
||||
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.can_ban_users {
|
||||
warn!("User {} is not allowed to ban users", user_session.user_token.as_str());
|
||||
return Err(AdvancedCommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
// 2. Look up the target user by username.
|
||||
let username_lower = request.username.to_lowercase();
|
||||
|
||||
let user_profile = get_user_profile_by_username(
|
||||
&username_lower,
|
||||
&server_state.mysql_pool,
|
||||
).await.map_err(|err| {
|
||||
warn!("User lookup error: {:?}", err);
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?.ok_or_else(|| {
|
||||
warn!("User not found for ban: {}", username_lower);
|
||||
AdvancedCommonWebError::NotFound
|
||||
})?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let audit_action = if request.is_banned {
|
||||
StaffAuditAction::BanUser
|
||||
} else {
|
||||
StaffAuditAction::UnbanUser
|
||||
};
|
||||
|
||||
info!(
|
||||
"Moderator {} {} user {} ({})",
|
||||
user_session.user_token.as_str(),
|
||||
audit_action.to_str(),
|
||||
user_profile.user_token.as_str(),
|
||||
username_lower,
|
||||
);
|
||||
|
||||
// 3. Begin transaction: ban + audit log.
|
||||
let mut transaction = server_state.mysql_pool.begin().await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to begin transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// 4. Set ban status.
|
||||
set_user_ban_status(SetUserBanStatusArgs {
|
||||
subject_user_token: &user_profile.user_token,
|
||||
is_banned: request.is_banned,
|
||||
mod_user_token: &user_session.user_token,
|
||||
maybe_mod_comments: Some(&request.mod_notes),
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Failed to set user ban status: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// 5. Insert staff audit log.
|
||||
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
|
||||
audit_action,
|
||||
maybe_entity_type: Some(StaffAuditEntityType::User),
|
||||
maybe_entity_token: Some(user_profile.user_token.as_str()),
|
||||
staff_user_token: &user_session.user_token,
|
||||
actor_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Failed to insert staff audit log: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// 6. Commit transaction.
|
||||
transaction.commit().await.map_err(|err| {
|
||||
warn!("Failed to commit transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"User {} ban status set to {} by moderator {}",
|
||||
user_profile.user_token.as_str(),
|
||||
request.is_banned,
|
||||
user_session.user_token.as_str(),
|
||||
);
|
||||
|
||||
Ok(Json(ModerationBanUserSuccessResponse {
|
||||
success: true,
|
||||
}))
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod moderator_list_user_session_impersonation_requests_for_user_handler;
|
||||
pub mod moderator_list_user_session_impersonation_requests_handler;
|
||||
pub mod moderator_user_session_impersonation_request_handler;
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
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::user_impersonation_requests::list_user_impersonation_requests_for_user::{
|
||||
list_user_impersonation_requests_for_user, ListUserImpersonationRequestsArgs,
|
||||
};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
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;
|
||||
|
||||
const CURSOR_NAME: &str = "moduserimp";
|
||||
const DEFAULT_LIMIT: u32 = 50;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
// --- Request ---
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListImpersonationRequestsPathInfo {
|
||||
user_token: UserToken,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListImpersonationRequestsQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListUserImpersonationRequestsSuccessResponse {
|
||||
pub success: bool,
|
||||
pub impersonation_requests: Vec<UserImpersonationRequestResponse>,
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserImpersonationRequestResponse {
|
||||
pub impersonator_user_token: UserToken,
|
||||
pub impersonator_username: String,
|
||||
pub impersonator_display_name: String,
|
||||
pub impersonated_user_token: UserToken,
|
||||
pub impersonated_username: String,
|
||||
pub impersonated_display_name: String,
|
||||
pub is_redeemed: bool,
|
||||
pub is_expired: bool,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
/// List impersonation requests for a given user. Moderators only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/user_sessions/impersonation_requests/user/{user_token}",
|
||||
params(
|
||||
ListImpersonationRequestsQueryParams,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ListUserImpersonationRequestsSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_list_user_session_impersonation_requests_for_user_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListImpersonationRequestsPathInfo>,
|
||||
query: Query<ListImpersonationRequestsQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListUserImpersonationRequestsSuccessResponse>, AdvancedCommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(
|
||||
&http_request,
|
||||
&server_state,
|
||||
UseDatabase::GrabNewConnection,
|
||||
).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
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);
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
}
|
||||
};
|
||||
|
||||
let records = list_user_impersonation_requests_for_user(
|
||||
ListUserImpersonationRequestsArgs {
|
||||
user_token: path.user_token.as_str(),
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
},
|
||||
).await.map_err(|err| {
|
||||
warn!("Failed to list impersonation requests: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = records.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);
|
||||
AdvancedCommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
|
||||
let impersonation_requests = records.into_iter().map(|r| {
|
||||
UserImpersonationRequestResponse {
|
||||
impersonator_user_token: r.impersonator_user_token,
|
||||
impersonator_username: r.impersonator_username,
|
||||
impersonator_display_name: r.impersonator_display_name,
|
||||
impersonated_user_token: r.impersonated_user_token,
|
||||
impersonated_username: r.impersonated_username,
|
||||
impersonated_display_name: r.impersonated_display_name,
|
||||
is_redeemed: r.is_redeemed,
|
||||
is_expired: r.is_expired,
|
||||
expires_at: r.expires_at,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(Json(ListUserImpersonationRequestsSuccessResponse {
|
||||
success: true,
|
||||
impersonation_requests,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use mysql_queries::queries::user_impersonation_requests::list_user_impersonation_requests::{
|
||||
list_user_impersonation_requests, ListUserImpersonationRequestsArgs,
|
||||
};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
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;
|
||||
|
||||
const CURSOR_NAME: &str = "modallimp";
|
||||
const DEFAULT_LIMIT: u32 = 50;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
// --- Request ---
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListAllImpersonationRequestsQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListAllImpersonationRequestsSuccessResponse {
|
||||
pub success: bool,
|
||||
pub impersonation_requests: Vec<AllImpersonationRequestResponse>,
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AllImpersonationRequestResponse {
|
||||
pub impersonator_user_token: UserToken,
|
||||
pub impersonator_username: String,
|
||||
pub impersonator_display_name: String,
|
||||
pub impersonated_user_token: UserToken,
|
||||
pub impersonated_username: String,
|
||||
pub impersonated_display_name: String,
|
||||
pub is_redeemed: bool,
|
||||
pub is_expired: bool,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
/// List all impersonation requests (not scoped to a user). Moderators only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/user_sessions/impersonation_requests/list",
|
||||
params(
|
||||
ListAllImpersonationRequestsQueryParams,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ListAllImpersonationRequestsSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_list_user_session_impersonation_requests_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListAllImpersonationRequestsQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListAllImpersonationRequestsSuccessResponse>, AdvancedCommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(
|
||||
&http_request,
|
||||
&server_state,
|
||||
UseDatabase::GrabNewConnection,
|
||||
).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
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);
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
}
|
||||
};
|
||||
|
||||
let records = list_user_impersonation_requests(
|
||||
ListUserImpersonationRequestsArgs {
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
},
|
||||
).await.map_err(|err| {
|
||||
warn!("Failed to list impersonation requests: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = records.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);
|
||||
AdvancedCommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
|
||||
let impersonation_requests = records.into_iter().map(|r| {
|
||||
AllImpersonationRequestResponse {
|
||||
impersonator_user_token: r.impersonator_user_token,
|
||||
impersonator_username: r.impersonator_username,
|
||||
impersonator_display_name: r.impersonator_display_name,
|
||||
impersonated_user_token: r.impersonated_user_token,
|
||||
impersonated_username: r.impersonated_username,
|
||||
impersonated_display_name: r.impersonated_display_name,
|
||||
is_redeemed: r.is_redeemed,
|
||||
is_expired: r.is_expired,
|
||||
expires_at: r.expires_at,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(Json(ListAllImpersonationRequestsSuccessResponse {
|
||||
success: true,
|
||||
impersonation_requests,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
+273
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
-33
@@ -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)
|
||||
}
|
||||
|
||||
+2
-174
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
+26
-2
@@ -29,13 +29,17 @@ 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::get_w2l_inference_queue_count::get_w2l_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::ban_user::ban_user_handler;
|
||||
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_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;
|
||||
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
|
||||
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
|
||||
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler;
|
||||
|
||||
pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
where
|
||||
@@ -103,6 +107,26 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::scope("/staff_audit_logs")
|
||||
.service(web::resource("/list")
|
||||
.route(web::get().to(moderator_list_staff_audit_logs_handler))
|
||||
.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("/impersonation_requests/list")
|
||||
.route(web::get().to(moderator_list_user_session_impersonation_requests_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/impersonation_requests/user/{user_token}")
|
||||
.route(web::get().to(moderator_list_user_session_impersonation_requests_for_user_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()))
|
||||
@@ -159,7 +183,7 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
web::scope("/user_bans")
|
||||
.service(
|
||||
web::resource("/manage_ban")
|
||||
.route(web::post().to(ban_user_handler))
|
||||
.route(web::post().to(moderation_ban_user_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -46,8 +46,6 @@ use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
|
||||
use chrono::Utc;
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use component_traits::traits::internal_user_lookup::InternalUserLookup;
|
||||
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
|
||||
use shared_env_var_config::redis::env_get_redis_0_connection_string_or_default;
|
||||
use elasticsearch::http::transport::Transport;
|
||||
use elasticsearch::Elasticsearch;
|
||||
use errors::AnyhowResult;
|
||||
@@ -60,6 +58,8 @@ use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
|
||||
use redis::Client;
|
||||
use redis_caching::redis_ttl_cache::RedisTtlCache;
|
||||
use reusable_types::server_environment::ServerEnvironment;
|
||||
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
|
||||
use shared_env_var_config::redis::env_get_redis_0_connection_string_or_default;
|
||||
use tokio::runtime::Runtime;
|
||||
use url_config::third_party_url_redirector::ThirdPartyUrlRedirector;
|
||||
use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge;
|
||||
@@ -71,16 +71,15 @@ use crate::billing::stripe_internal_user_lookup_impl::StripeInternalUserLookupIm
|
||||
use crate::configs::app_startup::redis_rate_limiters::configure_redis_rate_limiters;
|
||||
use crate::configs::connect_to_database::connect_to_database;
|
||||
use crate::configs::static_api_tokens::StaticApiTokenSet;
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use crate::http_server::middleware::error_alerting_middleware::error_alerting_middleware::ErrorAlertingMiddleware;
|
||||
use crate::http_server::middleware::pushback_filter_middleware::PushbackFilter;
|
||||
use crate::http_server::routes::add_routes::add_routes;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::handle_multipart_error::handle_multipart_error;
|
||||
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
|
||||
use crate::startup::build_pager::build_pager;
|
||||
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
|
||||
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
|
||||
use crate::startup::build_pager::build_pager;
|
||||
use crate::state::server_state::{DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
|
||||
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
|
||||
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
|
||||
@@ -89,6 +88,8 @@ use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
|
||||
use crate::util::encrypted_sort_id::SortKeyCrypto;
|
||||
use crate::util::troll_user_bans::load_troll_user_ban_list_from_directory::load_user_token_ban_list_from_directory;
|
||||
use crate::util::troll_user_bans::troll_user_ban_list::TrollUserBanList;
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use opaque_cursors::v2::opaque_cursor_encoder_v2::OpaqueCursorEncoderV2;
|
||||
|
||||
pub mod billing;
|
||||
pub mod configs;
|
||||
@@ -280,7 +281,9 @@ async fn main() -> AnyhowResult<()> {
|
||||
// We can even rotate it without too much impact to users.
|
||||
let sort_key_crypto_secret =
|
||||
easyenv::get_env_string_or_default("SORT_KEY_SECRET", "webscale");
|
||||
|
||||
let sort_key_crypto = SortKeyCrypto::new(&sort_key_crypto_secret);
|
||||
let opaque_cursor_encoder = OpaqueCursorEncoderV2::new(&sort_key_crypto_secret);
|
||||
|
||||
let health_check_interval = easyenv::get_env_duration_seconds_or_default(
|
||||
"HEALTH_CHECK_INTERVAL_SECS", Duration::from_secs(3));
|
||||
@@ -470,6 +473,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
auto_gc_bucket_client,
|
||||
audio_uploads_bucket_root,
|
||||
sort_key_crypto,
|
||||
opaque_cursors: opaque_cursor_encoder,
|
||||
static_api_token_set,
|
||||
fal: FalData {
|
||||
api_key: fal_api_key,
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
use crate::configs::app_startup::username_set::UsernameSet;
|
||||
use crate::configs::static_api_tokens::StaticApiTokenSet;
|
||||
use crate::http_server::deprecated_endpoints::categories::tts::list_fully_computed_assigned_tts_categories::list_fully_computed_assigned_tts_categories::ModelTokensByCategoryToken;
|
||||
use crate::http_server::deprecated_endpoints::leaderboard::get_leaderboard::LeaderboardInfo;
|
||||
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::ListFeaturedMediaFilesQueryParams;
|
||||
use crate::http_server::endpoints::stats::result_transformer::CacheableQueueStats;
|
||||
use crate::http_server::endpoints::tts::list_tts_models::TtsModelRecordForResponse;
|
||||
use crate::http_server::endpoints::voice_conversion::list_voice_conversion_models_handler::VoiceConversionModel;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::redis_rate_limiter::RedisRateLimiter;
|
||||
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
|
||||
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
|
||||
use crate::state::flags::paging_flags::PagingFlags;
|
||||
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
|
||||
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
|
||||
use crate::util::encrypted_sort_id::SortKeyCrypto;
|
||||
use crate::util::troll_user_bans::troll_user_ban_list::TrollUserBanList;
|
||||
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_helpers::middleware::banned_cidr_filter::banned_cidr_set::BannedCidrSet;
|
||||
use actix_helpers::middleware::banned_ip_filter::ip_ban_list::ip_ban_list::IpBanList;
|
||||
@@ -9,7 +28,6 @@ use elasticsearch::Elasticsearch;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
|
||||
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
|
||||
use pager::client::pager::Pager;
|
||||
use mysql_queries::mediators::badge_granter::BadgeGranter;
|
||||
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
|
||||
use mysql_queries::queries::generic_inference::web::get_pending_inference_job_count::InferenceQueueLengthResult;
|
||||
@@ -17,31 +35,14 @@ use mysql_queries::queries::media_files::list::list_featured_media_files::Featur
|
||||
use mysql_queries::queries::model_categories::list_categories_query_builder::CategoryList;
|
||||
use mysql_queries::queries::tts::tts_inference_jobs::get_pending_tts_inference_job_count::TtsQueueLengthResult;
|
||||
use mysql_queries::queries::w2l::w2l_templates::list_w2l_templates::W2lTemplateRecordForList;
|
||||
use opaque_cursors::v2::opaque_cursor_encoder_v2::OpaqueCursorEncoderV2;
|
||||
use pager::client::pager::Pager;
|
||||
use redis::Client;
|
||||
use redis_caching::redis_ttl_cache::RedisTtlCache;
|
||||
use reusable_types::server_environment::ServerEnvironment;
|
||||
use sqlx::MySqlPool;
|
||||
use url_config::third_party_url_redirector::ThirdPartyUrlRedirector;
|
||||
|
||||
use crate::configs::app_startup::username_set::UsernameSet;
|
||||
use crate::configs::static_api_tokens::StaticApiTokenSet;
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use crate::http_server::deprecated_endpoints::categories::tts::list_fully_computed_assigned_tts_categories::list_fully_computed_assigned_tts_categories::ModelTokensByCategoryToken;
|
||||
use crate::http_server::deprecated_endpoints::leaderboard::get_leaderboard::LeaderboardInfo;
|
||||
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::ListFeaturedMediaFilesQueryParams;
|
||||
use crate::http_server::endpoints::stats::result_transformer::CacheableQueueStats;
|
||||
use crate::http_server::endpoints::tts::list_tts_models::TtsModelRecordForResponse;
|
||||
use crate::http_server::endpoints::voice_conversion::list_voice_conversion_models_handler::VoiceConversionModel;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::state::flags::paging_flags::PagingFlags;
|
||||
use crate::http_server::web_utils::redis_rate_limiter::RedisRateLimiter;
|
||||
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
|
||||
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
|
||||
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
|
||||
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
|
||||
use crate::util::encrypted_sort_id::SortKeyCrypto;
|
||||
use crate::util::troll_user_bans::troll_user_ban_list::TrollUserBanList;
|
||||
|
||||
/// State that is injected into every endpoint.
|
||||
pub struct ServerState {
|
||||
/// Configuration from ENV vars.
|
||||
@@ -108,6 +109,7 @@ pub struct ServerState {
|
||||
pub audio_uploads_bucket_root: String,
|
||||
|
||||
pub sort_key_crypto: SortKeyCrypto,
|
||||
pub opaque_cursors: OpaqueCursorEncoderV2,
|
||||
|
||||
pub ip_ban_list: IpBanList,
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user