Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293a41c7d4 | ||
|
|
55ab800542 | ||
|
|
2c93b4cf42 | ||
|
|
9169e4d662 | ||
|
|
4c7af51851 | ||
|
|
eadfb4f821 | ||
|
|
0c163d6c19 | ||
|
|
26484685ee | ||
|
|
5bceb33801 | ||
|
|
c6e2c4296a | ||
|
|
8551d58def | ||
|
|
807180bf22 | ||
|
|
f48d2beea7 | ||
|
|
dc72bbd5e6 | ||
|
|
b6a184bcb0 | ||
|
|
d0e1d113fe | ||
|
|
1facb312dc | ||
|
|
f2c8a0390f | ||
|
|
2e83d39d2b | ||
|
|
4f1c9134ee | ||
|
|
f0a877c988 | ||
|
|
f77f09018a |
No files matched your search
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status: UserLoginChallengeStatus",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type: UserLoginChallengeFailureType",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74",
|
||||
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()\n"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "expired",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"max_size": 1,
|
||||
"type": "LongLong"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "1c618e9816d6f3f7f217770f988a132d9521693da6474e945628d1b3efbc4b80",
|
||||
"query": "\nSELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
}
|
||||
},
|
||||
"hash": "2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES (?, ?, ?, ?, ?, ?)\n "
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "session_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "459898b1b42a51097dcbc0f182bd0badce642b11bd33a7e1973a6cc25538ffbf",
|
||||
"query": "\nSELECT\n token as session_token,\n user_token\nFROM user_sessions\nWHERE token = ?\nAND deleted_at IS NULL\nAND expires_at > NOW()\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"hash": "705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = ?\n AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL\n AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,\n maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()\nWHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL\n"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "8bfbb8bef3bec235f37c5f4f5c2f3a8b0833241a63da122b8abd97cd9b71208f",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()\n AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE\n AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_address",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_gravatar_hash",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed_by_google",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_is_synthetic",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_without_password",
|
||||
"ordinal": 8,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username_is_not_customized",
|
||||
"ordinal": 9,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_stripe_customer_id",
|
||||
"ordinal": 10,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_loyalty_program_key",
|
||||
"ordinal": 11,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disable_gravatar",
|
||||
"ordinal": 12,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_audio_preference",
|
||||
"ordinal": 13,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_video_preference",
|
||||
"ordinal": 14,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 15,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 16,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_role_slug",
|
||||
"ordinal": 17,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_banned",
|
||||
"ordinal": 18,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_access_studio",
|
||||
"ordinal": 19,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_feature_flags",
|
||||
"ordinal": 20,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_tts",
|
||||
"ordinal": 21,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_w2l",
|
||||
"ordinal": 22,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_results",
|
||||
"ordinal": 23,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_results",
|
||||
"ordinal": 24,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_account",
|
||||
"ordinal": 25,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_tts_models",
|
||||
"ordinal": 26,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_w2l_templates",
|
||||
"ordinal": 27,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_models",
|
||||
"ordinal": 28,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_templates",
|
||||
"ordinal": 29,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_approve_w2l_templates",
|
||||
"ordinal": 30,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_profiles",
|
||||
"ordinal": 31,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_tts_models",
|
||||
"ordinal": 32,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_w2l_templates",
|
||||
"ordinal": 33,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_models",
|
||||
"ordinal": 34,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_results",
|
||||
"ordinal": 35,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_templates",
|
||||
"ordinal": 36,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_results",
|
||||
"ordinal": 37,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_ban_users",
|
||||
"ordinal": 38,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_users",
|
||||
"ordinal": 39,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "934aad794dcb0057e39c471e7b25e5e191a395a2bd16553cb58a483b91c68437",
|
||||
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND user_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status: UserLoginChallengeStatus",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type: UserLoginChallengeFailureType",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6",
|
||||
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
}
|
||||
},
|
||||
"hash": "cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80",
|
||||
"query": "\nINSERT INTO user_login_challenges\n (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)\nVALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
}
|
||||
},
|
||||
"hash": "da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,\n maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,\n maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())\nWHERE id = ? AND status = ? AND expires_at > NOW()\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c",
|
||||
"query": "\nINSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)\nSELECT ?, token, ?, ?, NOW() + INTERVAL 1 YEAR FROM users\nWHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL\n"
|
||||
}
|
||||
@@ -32,12 +32,45 @@ artcraft-services/
|
||||
│ ├── plugins/ # Shared billing and service components
|
||||
│ └── web/storyteller_web/ # Main Actix Web HTTP API
|
||||
├── frontend/
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website/ # Product website at getartcraft.com
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
|
||||
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
└── Cargo.toml # Rust workspace
|
||||
```
|
||||
|
||||
## Updating Desktop Download Links
|
||||
|
||||
Desktop release links are configured independently in **three places**. When
|
||||
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
|
||||
`MAC_VERSION` in all three unless the request explicitly targets one platform or
|
||||
app:
|
||||
|
||||
1. **Current public website (Next.js):**
|
||||
[frontend/apps/artcraft-website-next/src/lib/download-links.ts](frontend/apps/artcraft-website-next/src/lib/download-links.ts).
|
||||
This controls the installer links and visible version at
|
||||
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
|
||||
(newest first), then update both version selectors.
|
||||
2. **Legacy website (Vite):**
|
||||
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
|
||||
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
|
||||
Updating only this file does **not** update the current public website.
|
||||
3. **Browser webapp:**
|
||||
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
|
||||
Update both version constants; these links are used by the welcome and
|
||||
checkout-success pages at `app.getartcraft.com`.
|
||||
|
||||
Keep previous releases for rollback. The Next.js download page reads its visible
|
||||
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
|
||||
not hardcode a separate version in the page component.
|
||||
|
||||
Check that all three configs produce the requested release tag and installer
|
||||
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
|
||||
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
|
||||
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
|
||||
require deployment of the affected apps before the public pages change; do not
|
||||
report a local update as a deployed update.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Rust with no minimum supported version
|
||||
@@ -63,6 +96,18 @@ Organize for top-to-bottom reading. Important things first, details later.
|
||||
- Among helpers: meatier logic above leaf-level formatters
|
||||
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
|
||||
|
||||
## Public API Compatibility
|
||||
|
||||
- Public HTTP endpoints have deployed legacy clients. Preserve their request and
|
||||
response schemas, defaults, limits, and pagination behavior when optimizing them.
|
||||
- Put a changed contract behind a new endpoint/version and update new clients to
|
||||
call it explicitly. Do not add a new mode or change response fields on an existing
|
||||
public endpoint as part of a performance fix.
|
||||
- For an endpoint-local revision, version the operation path (for example,
|
||||
`/v1/media_files/list_v2/user/{username}`). Reserve a top-level `/v2` for broader
|
||||
API changes.
|
||||
- Add regression tests for the original contract alongside tests for the new route.
|
||||
|
||||
## Markdown
|
||||
|
||||
- **Tables must be space-padded so columns align in plain text.** Markdown
|
||||
|
||||
Generated
+2
@@ -8764,6 +8764,7 @@ dependencies = [
|
||||
"redis_common",
|
||||
"redis_schema",
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"resend-rs",
|
||||
"reusable_types",
|
||||
"rootly_client",
|
||||
@@ -8775,6 +8776,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"sha1_hash",
|
||||
"sha2 0.11.0-rc.5",
|
||||
"shared_env_var_config",
|
||||
"speculoos",
|
||||
"sqlx",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_login_challenges;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
|
||||
status VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_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_creation_type;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
ALTER TABLE user_sessions
|
||||
ADD COLUMN maybe_creation_type VARCHAR(32) DEFAULT NULL;
|
||||
@@ -24,6 +24,11 @@ These are created with the `diesel` tool, and in particular:
|
||||
|
||||
- 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`.
|
||||
- Application-owned state/status/type columns must not have schema defaults. Use
|
||||
`NOT NULL` without `DEFAULT` when required, and have application inserts explicitly
|
||||
supply the value. Document the corresponding Rust enum; application queries bind its
|
||||
variants instead of hardcoding enum strings in SQL. See
|
||||
`crates/schema/database/mysql_queries/AGENTS.md` for the query rules.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
@@ -33,6 +38,7 @@ These are created with the `diesel` tool, and in particular:
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep documentation in this directory as regular files, not symlinks. Diesel can treat a documentation symlink as a migration directory and reject the entire migration list.
|
||||
- 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`).
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,6 @@
|
||||
# SQL Migrations
|
||||
|
||||
Read and follow [AGENTS.md](AGENTS.md) in this directory.
|
||||
|
||||
This must remain a regular file. Diesel treats a symlink here as a migration
|
||||
directory and fails with `Invalid migration directory`.
|
||||
@@ -13,6 +13,9 @@ Update the corresponding squashed file whenever a migration changes a table's sc
|
||||
|
||||
- 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`.
|
||||
- Application-owned state/status/type columns must not have schema defaults. Required
|
||||
values stay `NOT NULL` and are supplied explicitly by application code using the
|
||||
corresponding Rust enum. Keep these definitions consistent with the applied migrations.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
|
||||
status VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -41,6 +41,12 @@ CREATE TABLE user_sessions (
|
||||
-- designed to be recoverable.
|
||||
deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
maybe_creation_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# Website-approved device login
|
||||
|
||||
Implemented across storyteller-web, the browser approval page, and the Tauri
|
||||
login dialog. Apply the two migrations before deploying the API; deploy the web
|
||||
approval route before releasing the desktop buttons. No production migrations or
|
||||
deployments were performed while developing this change.
|
||||
|
||||
Call the flow **device approval**, and each attempt a **login challenge**. It works
|
||||
for a desktop browser link or a phone scanning a QR code, regardless of how the
|
||||
approving account signed in. It creates an ordinary cookie-based web session.
|
||||
|
||||
The design borrows separate device/browser credentials, polling, explicit consent,
|
||||
and code comparison from [OAuth device authorization (RFC 8628)](https://www.rfc-editor.org/rfc/rfc8628.html#section-3.3.1).
|
||||
It is an ArtCraft session bridge, not an OAuth token endpoint implementation.
|
||||
|
||||
## Two migrations
|
||||
|
||||
1. `2026-09-25-120000-0000_create_user_login_challenges` creates the challenge and
|
||||
its terminal outcome audit record together. A separate failure table is not
|
||||
needed for one immutable outcome per attempt. Status is `NOT NULL` without a
|
||||
default; inserts explicitly bind `UserLoginChallengeStatus::Pending`.
|
||||
2. `2026-09-25-120000-0001_alter_user_sessions_add_maybe_creation_type` adds nullable
|
||||
provenance to cookie sessions. Existing rows and older writers retain NULL.
|
||||
|
||||
Both have reverse migrations and matching materialized schemas under
|
||||
`migrations_squashed/user_tables/`. Deploy the schema before new writers. Roll
|
||||
back feature code before dropping the column/table; rollback removes bridge audit
|
||||
records but does not revoke sessions already issued.
|
||||
|
||||
`UserWebSessionCreationType` values:
|
||||
|
||||
| Rust variant | Stored value | Meaning |
|
||||
|------------------|-------------------|-------------------------------------------------------------------|
|
||||
| DirectLogin | direct_login | Password or SSO login/signup, including desktop password login. |
|
||||
| DeviceApproval | device_approval | A new session minted from an approved login challenge. |
|
||||
| Impersonation | impersonation | Existing consented staff impersonation flow. |
|
||||
| None | NULL | Unknown provenance, including existing rows and older writers. |
|
||||
|
||||
`DirectLogin` describes the creation mechanism more accurately than `Web`, since
|
||||
ordinary password login is also available in the desktop app. This field does not
|
||||
replace `maybe_impersonation_user_token` or influence authentication/authorization.
|
||||
MCP and API credentials continue using `mcp_sessions` and `api_keys` respectively.
|
||||
Existing password/SSO and impersonation session writers now record provenance.
|
||||
There is no historical backfill or default that would invent it.
|
||||
|
||||
## Credentials and confirmation
|
||||
|
||||
Generate two independent 32-byte CSPRNG values per challenge. Encode each as
|
||||
unpadded base64url (43 ASCII characters). Store only the SHA-256 digest of that
|
||||
canonical ASCII representation in the two uniquely indexed `BINARY(32)` columns.
|
||||
Reject noncanonical input instead of accepting multiple representations. These
|
||||
are random tokens, not passwords, so a fast cryptographic hash is appropriate.
|
||||
|
||||
| Value | Who receives it | What it permits |
|
||||
|---------------------|-------------------------------|--------------------------------------------------------|
|
||||
| token | Internal logs/support | Identifying a row; no authentication. |
|
||||
| approval token | Desktop, browser link, QR | Review and authenticated approval/decline only. |
|
||||
| device token | Initiating desktop only | Polling and collecting the newly authorized session. |
|
||||
| confirmation code | Desktop and approval screen | Visual comparison only; no lookup or authentication. |
|
||||
|
||||
Use eight uniformly random characters from `BCDFGHJKLMNPQRSTVWXZ` for the code.
|
||||
Store `WDJBMJHT`, display `WDJB-MJHT`, and ask the user to verify the match. It need
|
||||
not be unique because it is never used to locate or authorize a challenge. Manual
|
||||
short-code entry is outside this initial flow; both entry points carry the full
|
||||
approval token. The private device token must never be in the URL or QR code.
|
||||
|
||||
The website displays the requesting IP, the account being authorized, the matching
|
||||
code, and explicit **Approve desktop login** / **Decline** actions. An existing
|
||||
website session skips only the login screen, never consent. If login is needed,
|
||||
keep the pending approval context in first-party browser state and resume the
|
||||
confirmation page after password or Google SSO login. Recheck expiry after login.
|
||||
Do not send the bridge tokens to Google or another identity provider. Allow only
|
||||
the fixed first-party return route, not an arbitrary caller-supplied redirect URL.
|
||||
|
||||
The approval page is a read-only GET; link scanners and previews cannot decide
|
||||
the challenge. Decisions require a valid, non-impersonated user web session plus
|
||||
CSRF/origin protection. Never accept an API key or MCP credential as consent.
|
||||
Require the user to have initiated the request on a device in their possession;
|
||||
the visible code and IP help detect phishing but are not authentication factors.
|
||||
|
||||
The approval URL uses HTTPS and carries the browser credential in its fragment.
|
||||
The page captures and removes that fragment before referral collection or React
|
||||
providers initialize, skips analytics/referral tracking on direct entry, and sets
|
||||
a no-referrer policy. API responses send `Cache-Control: no-store` and
|
||||
`Referrer-Policy: no-referrer`; API credentials travel in JSON bodies, never query
|
||||
strings. Shared fonts and the Google sign-in provider still load. Keep request and
|
||||
response bodies out of gateway logs. Invalid credentials reveal no account or
|
||||
challenge details. The IP follows the existing trusted-gateway forwarding policy;
|
||||
the gateway must replace untrusted incoming forwarding headers.
|
||||
|
||||
## State and the 20-minute deadline
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> approved: authenticated approval
|
||||
pending --> failed: user_declined
|
||||
pending --> failed: expired
|
||||
approved --> failed: expired before redemption
|
||||
approved --> redeemed: atomically mint one session
|
||||
redeemed --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
Set `created_at = NOW()` and `expires_at = NOW() + INTERVAL 20 MINUTE` in the same
|
||||
INSERT using the database's clock. The default expiry is immediate so a writer
|
||||
that omits it fails closed. Neither approval nor polling extends this deadline.
|
||||
All eligibility checks require `expires_at > NOW()`; equality means expired.
|
||||
The create query enforces this exact lifetime; these migrations do not add
|
||||
triggers or database CHECK constraints. Keep database connections in UTC.
|
||||
|
||||
The persisted status alone is insufficient to decide validity. A pending or
|
||||
approved row whose deadline passed is already expired even before a sweeper
|
||||
marks it `failed`. Approvals and first redemptions must check the deadline again
|
||||
under the row lock. Polls are normally read-only and do not touch `updated_at`.
|
||||
|
||||
| Status | Required accompanying data |
|
||||
|------------|-------------------------------------------------------------------------------------|
|
||||
| pending | No decision, failure, redemption, or session reference. |
|
||||
| approved | Deciding user, decision IP/time; no failure, redemption, or session reference. |
|
||||
| redeemed | Approval data plus session ID, first redemption IP/time; no failure. |
|
||||
| failed | Failure type/time; no redemption or session reference; retain any prior approval. |
|
||||
|
||||
`UserLoginChallengeFailureType` initially has two values:
|
||||
|
||||
- `user_declined`: record the deciding user, browser IP in both decision/failure
|
||||
IP fields, and the decision time in both decision/failure timestamps. Keep the
|
||||
original desktop IP. Polling with the valid device token reports the decline.
|
||||
- `expired`: record `maybe_failed_at = expires_at`. Retain original desktop IP and
|
||||
any prior approval, but leave the failure IP NULL: a timeout has no actor. A
|
||||
scheduled sweep records abandoned requests even when nobody polls again.
|
||||
|
||||
Failed challenges are unusable for approval or redemption. Their digests and
|
||||
audit metadata remain for diagnosing failures; do not physically delete the row
|
||||
when a user declines. A new attempt creates a new row and fresh tokens. Closing
|
||||
the desktop dialog stops polling; unused requests expire normally.
|
||||
|
||||
Invalid tokens, failed website sign-ins, throttling, and transient infrastructure
|
||||
errors do not terminally fail a valid challenge. Record those requests in the
|
||||
existing operational/abuse logging with their IP and failure category, without
|
||||
credentials. A second decision cannot replace the first actor or outcome.
|
||||
|
||||
## Atomic redemption and response loss
|
||||
|
||||
The website's approval records consent; it never creates or receives the desktop
|
||||
cookie. The desktop's authenticated poll performs redemption after approval:
|
||||
|
||||
1. Begin an InnoDB transaction on the primary and find the challenge by the device
|
||||
token digest with `SELECT ... FOR UPDATE`.
|
||||
2. Validate the immutable deadline and state after taking the lock. Handle
|
||||
failed/pending states without creating a session. Revalidate that the approved
|
||||
user exists and is permitted to log in under the ordinary session rules.
|
||||
3. For `approved`, insert a fresh `user_sessions` row owned by the deciding user,
|
||||
with `maybe_creation_type = 'device_approval'`, no impersonation, the current
|
||||
desktop redemption IP, and the ordinary web-session lifetime.
|
||||
4. Store its numeric ID, redemption IP/time, and `status = 'redeemed'` on the
|
||||
challenge in that same transaction. Generate the signed cookie using
|
||||
`HttpUserSessionManager::create_cookie` before commit, so signing failure rolls
|
||||
back too. Commit successfully before returning any credential to the caller.
|
||||
|
||||
Both approval/decline and redemption must serialize on the same challenge row;
|
||||
conditional updates include the expected state and the unexpired deadline, and
|
||||
must check rows affected. SQL for this flow belongs in `mysql_queries`, with
|
||||
executor/transaction support, not in the web handler. All three Rust enums in this
|
||||
change are database enums; separate client-facing status/failure enums include
|
||||
an unknown-value fallback. Desktop callers fail closed on an unknown state.
|
||||
|
||||
One-time means **at most one new session per challenge**. If the first successful
|
||||
HTTP response is lost, a retry with the same device token may return the same
|
||||
session until the original 20-minute deadline. For `redeemed`, read the linked
|
||||
session and verify its owner, creation type, absence of impersonation, expiry,
|
||||
deletion state, and the user's current eligibility before reconstructing its
|
||||
cookie. Never mint a replacement or resurrect a deleted session. After the
|
||||
deadline, return expiry without a cookie; the already-issued session keeps its
|
||||
normal lifetime, and the audit outcome remains `redeemed`.
|
||||
|
||||
The unique nullable session ID index prevents two challenges from referencing one
|
||||
session and supports retries without duplicating bearer credentials in this
|
||||
table. The reference is intentionally signed because the legacy `user_sessions.id`
|
||||
is signed. Like neighboring schemas, references are indexed logical links rather
|
||||
than cascading foreign keys; removal of a session must not erase the audit row.
|
||||
If the referenced session is missing, redemption fails closed.
|
||||
|
||||
## Implemented endpoints and clients
|
||||
|
||||
All endpoints are JSON POSTs under `/v1/login_challenges`:
|
||||
|
||||
| Endpoint | Authentication | Effect |
|
||||
|------------|---------------------------------------------|--------------------------------------------------------------|
|
||||
| create | None; limited by requesting IP | Returns device token, verification URL, code, expiry. |
|
||||
| review | Approval token + web session + Origin | Returns code, requesting IP, account, state, expiry. |
|
||||
| decide | Approval token + web session + Origin | Records the first Yes/No; never creates a desktop session. |
|
||||
| poll | Private device token | Reports state or atomically redeems approved consent. |
|
||||
|
||||
The API advertises five-second polling. Per-process IP limits are ten creates and
|
||||
120 other requests per minute, shared across Actix workers, with bounded memory.
|
||||
Production gateways can additionally enforce aggregate limits across replicas.
|
||||
A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention
|
||||
is intentionally not an automatic purge policy in this change.
|
||||
|
||||
Both browser applications implement `/login/desktop`: `https://getartcraft.com`
|
||||
(including `www`) and `https://app.getartcraft.com`. They share the approval UI in
|
||||
`frontend/libs/login`. The API currently advertises the webapp URL. Local API
|
||||
requests advertise `http://localhost:4201/login/desktop`; the website also handles
|
||||
the route on port 4200. Run either frontend with `USE_LOCAL_API=1` so approval
|
||||
reaches the same local API; local origins cannot approve production challenges.
|
||||
The website signs in inline to preserve its origin-scoped pending token and
|
||||
mobile session header. The webapp preserves the return destination through login,
|
||||
signup, and password reset, bypassing billing redirects for desktop consent.
|
||||
Tokens stay in tab-scoped session storage, are removed from URL fragments before
|
||||
tracking, and are cleared on terminal results. Returning from authentication
|
||||
always requires explicit consent. The code stays visible for comparison; a single
|
||||
Approve or Decline click records the choice, without an extra checkbox.
|
||||
The desktop displays the QR code, code comparison, countdown, and status. Opening
|
||||
the website invokes the native system browser. Network errors back off up to 30
|
||||
seconds; closing the dialog stops polling and cancels the native handle. A response
|
||||
arriving after cancellation cannot install credentials.
|
||||
|
||||
Successful polling returns the signed value and the normal `Set-Cookie` header.
|
||||
Tauri commands use the Rust `artcraft_client` bindings for creation, polling, and
|
||||
ordinary session verification, all against `AppEnvConfigs.storyteller_host`. Rust
|
||||
keeps the private device token and signed session out of IPC/JavaScript; the UI
|
||||
holds only a local handle and public display data. After verifying the downstream
|
||||
session, Rust installs and persists the cookie in the native HTTP jar and updates
|
||||
the credential manager. No browser session is copied into the desktop.
|
||||
The desktop login/signup modal also uses Rust commands for its initial session
|
||||
check and password forms. Website/QR buttons appear only in login mode. Unknown
|
||||
optional feature flags in the session response are ignored by older desktops;
|
||||
malformed authentication data still fails verification. A stale initial session
|
||||
check cannot reset a pending or completed login.
|
||||
|
||||
## Integration tests
|
||||
|
||||
The backend harness uses the fixed local Unix socket `/tmp/mysql.sock` and local
|
||||
root account. It never reads `DATABASE_URL`, any environment database URL, DNS,
|
||||
or a TCP endpoint (including localhost SSH forwarding). Each test creates a new
|
||||
randomly named `artcraft_test_bridge_*` database, replays all migrations, and drops
|
||||
only its own database on success. Existing `storyteller` and `artcraft_test` data
|
||||
are not used. A failed test leaves its isolated database for diagnosis. The local
|
||||
MySQL instance must be running; there is no fallback to a remote database.
|
||||
|
||||
The HTTP endpoint suite is
|
||||
`crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs`.
|
||||
It starts the real challenge handlers and `/v1/session` handler on an ephemeral
|
||||
`127.0.0.1` port. Separate browser and desktop HTTP clients maintain their own
|
||||
cookie jars; requests cannot follow redirects or use system proxies. Fixtures in
|
||||
`mysql_testing::fixtures::login_challenges` create passwordless users, Google email
|
||||
confirmation, feature flags (including `use_qt`), and signed browser sessions.
|
||||
The desktop cookie comes only from the real redemption response and is then sent
|
||||
to the real session endpoint. HTTP responses and SQL queries are not mocked.
|
||||
|
||||
From the services repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
|
||||
cd frontend
|
||||
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
|
||||
npm exec vitest -- run --config apps/artcraft-website/vite.config.ts src/pages/login/desktop-login.spec.tsx
|
||||
```
|
||||
|
||||
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
|
||||
real SQL against local MySQL. Do not enable `skip_database_tests` for this run.
|
||||
|
||||
To run only the four HTTP endpoint tests with database fixtures:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges::endpoint_integration_tests
|
||||
```
|
||||
|
||||
From the desktop repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
|
||||
cd frontend
|
||||
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
|
||||
```
|
||||
|
||||
Coverage:
|
||||
|
||||
- Four HTTP endpoint integration tests with database fixtures: the complete
|
||||
create/review/approve/redeem/session flow, independent browser/desktop cookies,
|
||||
passwordless onboarding and `use_qt` serialization, concurrent redemption,
|
||||
lost-response recovery with a fresh client, no sessions before redemption,
|
||||
audit ownership/IPs/timestamps, decline, pending/approved expiry, downstream
|
||||
session revocation/expiry/bans, and immutable approval ownership across accounts.
|
||||
- Seven additional backend handler integration tests: both production browser origins, passwordless account approval, zero sessions
|
||||
before redemption, rejection/audit IPs, token separation, CSRF, MCP rejection,
|
||||
invalid approving sessions, concurrent decisions/redemptions, response-loss
|
||||
retries, abandoned expiry, expiry during lock contention, revocation, bans,
|
||||
signed-cookie authentication through the production session guard, and an issued
|
||||
session remaining valid after the challenge expires.
|
||||
- Twenty-five browser tests: API/consent interaction, code/IP display, decline,
|
||||
expiry, terminal/unknown states, lost-response retries, safe return URLs,
|
||||
website inline Google/password sign-in, and webapp login/signup/password-reset
|
||||
continuation without subscription gating or bridge credentials in SSO requests.
|
||||
- Thirteen desktop UI tests: IPC-only transport (JavaScript HTTP is forbidden),
|
||||
native browser invocation, QR/code values, polling, verified user results,
|
||||
decline, timeout, network backoff, cancellation, host/status diagnostics,
|
||||
login-only button placement, native password forms, and stale session checks.
|
||||
- Thirteen native tests: real API bindings against ephemeral loopback HTTP servers,
|
||||
downstream verification before cookie installation, persistence, retries,
|
||||
rejection, timeout, cancellation during HTTP, unknown states, no redirects,
|
||||
redacted errors, local/production origin isolation, visitor-only telemetry,
|
||||
forward-compatible feature flags, malformed session data, and native password
|
||||
login/signup/session recheck. Desktop tests do not connect to MySQL.
|
||||
|
||||
Frontend tests replace external Google/native boundaries; they do not
|
||||
perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests
|
||||
use real MySQL and the production cookie signer/session guard with Redis disabled;
|
||||
the HTTP suite also exercises the real session endpoint and its serialized body.
|
||||
These endpoint tests do not drive a live Google OAuth exchange or the Tauri GUI.
|
||||
Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired
|
||||
sessions; bridge consent/redemption always checks MySQL directly.
|
||||
|
||||
Validation also covered both migration directions, nullable compatibility with
|
||||
legacy session writers, uniqueness constraints, fail-closed expiry defaults, and
|
||||
matching materialized schemas. The focused web TypeScript check and the production
|
||||
builds of both browser apps (including 53 dependency tasks) passed. Broad workspace TypeScript
|
||||
checks have existing errors. The desktop Nx build is blocked by the existing
|
||||
`api -> tauri-api -> api` cycle; its direct Vite login bundle builds but declaration
|
||||
checks report unavailable dependency outputs. A source-only desktop check reports
|
||||
four existing `model-list` imports of `src/index.js`, with no bridge-file errors.
|
||||
The full native Rust test target compiled successfully.
|
||||
@@ -0,0 +1,73 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeRequest {}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct DecideLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
pub approve: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct PollLoginChallengeRequest {
|
||||
pub device_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub device_token: String,
|
||||
pub verification_url: String,
|
||||
pub confirmation_code: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub poll_interval_seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
pub confirmation_code: String,
|
||||
pub requesting_ip: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
/// Present only to the device after the redemption transaction commits.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_signed_session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeState {
|
||||
Pending,
|
||||
Approved,
|
||||
Redeemed,
|
||||
Failed,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeFailure {
|
||||
UserDeclined,
|
||||
Expired,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod change_password;
|
||||
pub mod edit_email;
|
||||
pub mod edit_username;
|
||||
pub mod login;
|
||||
pub mod session_info;
|
||||
pub mod login_challenges;
|
||||
pub mod session_info;
|
||||
+14
-11
@@ -8,6 +8,7 @@ use enums::common::view_as::ViewAs;
|
||||
use errors::{anyhow, AnyhowResult};
|
||||
use mysql_queries::queries::media_files::delete::delete_media_file::delete_media_file_as_mod;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
|
||||
|
||||
use crate::cli_args::Args;
|
||||
@@ -42,23 +43,25 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
|
||||
info!("Querying page {page_index} of files...");
|
||||
|
||||
let media_files = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
page_size: 100,
|
||||
page_index,
|
||||
filters: UserMediaFileFilters {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
view_as: ViewAs::Moderator,
|
||||
},
|
||||
limit: 100,
|
||||
offset: page_index * 100,
|
||||
sort_ascending: false,
|
||||
view_as: ViewAs::Moderator,
|
||||
mysql_pool: &mysql,
|
||||
mysql_executor: mysql,
|
||||
}).await?;
|
||||
|
||||
if media_files.records.is_empty() {
|
||||
if media_files.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for file in media_files.records {
|
||||
for file in media_files {
|
||||
info!("Deleting file: {:?}", &file.token);
|
||||
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
|
||||
}
|
||||
|
||||
@@ -56,24 +56,72 @@ schema metadata. For **new tables**, you may need to:
|
||||
|
||||
- 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`.
|
||||
- Do not give application-owned state/status/type columns schema defaults. Keep required
|
||||
columns `NOT NULL` without a `DEFAULT`; application code must explicitly bind their
|
||||
initial value on every insert. Keep migrations and materialized schema docs in sync.
|
||||
|
||||
### Database enum values
|
||||
|
||||
- Use the Rust enums in `enums::by_table` for database state, status, failure, and type
|
||||
values. Do not hardcode their string representations in SQL or Rust business logic.
|
||||
- Bind enum values (or their `to_str()` representation) through SQL parameters, including
|
||||
`SET`, `WHERE`, `IN`, and `INSERT` values. For example, bind
|
||||
`UserLoginChallengeStatus::Approved.to_str()` to `status = ?`.
|
||||
- Query args and returned rows must use the enum type rather than `String`/`&str` for
|
||||
enum columns. Use SQLx column type overrides and the enum's MySQL decoder on reads;
|
||||
use exhaustive variant matches in application logic. Preserve existing stored values.
|
||||
|
||||
### 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
|
||||
### Named arguments and generic transactors
|
||||
|
||||
Use the executor-generic `Args` pattern for queries that need to work inside transactions:
|
||||
Every new or refactored query must take a single named `<QueryName>Args` struct containing
|
||||
all inputs, including `mysql_executor`. Do not use positional parameter lists, even for
|
||||
small queries. Keep the args struct in the same file as its query.
|
||||
|
||||
Use a generic transactor `T` bounded by `Executor<'c, Database = MySql>`. Do not constrain
|
||||
query functions to `MySqlConnection`, `MySqlPool`, a concrete transaction, or the legacy
|
||||
`Transactor` enum. This applies to all queries, not only ones currently used in transactions.
|
||||
Put the executor bound on the function; the args struct need not carry executor lifetimes
|
||||
or `PhantomData` when it simply stores `T`.
|
||||
|
||||
```rust
|
||||
pub struct MyQueryArgs<'a, 'c: 'a, E>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct MyQueryArgs<'a, T> {
|
||||
pub field: &'a str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn my_query<'c, T>(args: MyQueryArgs<'_, T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!("UPDATE my_table SET name = ?", args.field)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
This allows the same query function to accept both `&MySqlPool` and `&mut Transaction`.
|
||||
Call with `mysql_executor: &pool` for a pool, or `mysql_executor: &mut *tx` /
|
||||
`mysql_executor: &mut *conn` to reborrow the connection held by an owned SQLx transaction /
|
||||
pool connection. SQLx transactions do not themselves implement `Executor`; dereference
|
||||
them at the call site. Reuse the caller's transaction when atomicity or row locks matter.
|
||||
Do not acquire a new connection or begin/commit a transaction inside a query helper.
|
||||
|
||||
### One query per file, grouped by table
|
||||
|
||||
- Put each query in its own descriptively named `.rs` file under the table's directory
|
||||
module, e.g. `queries/user_login_challenges/decide_challenge.rs` or the existing
|
||||
`queries/users/user_sessions/find_redeemed_session.rs`.
|
||||
- Each query file contains one SQL statement/query function, its args struct, and any
|
||||
result type used only by that query. Put shared result types in a separate type file.
|
||||
- Keep `mod.rs` for module declarations; do not collect SQL in it or in catch-all files
|
||||
such as `challenge_queries.rs` or `session_queries.rs`.
|
||||
- Split multi-query operations into individual query files. Keep transaction sequencing
|
||||
and business decisions in the caller/service layer, with every statement using the
|
||||
same transaction when required.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use super::user_media_file_filters::UserMediaFileFilters;
|
||||
|
||||
pub struct CountMediaFilesForUserArgs<'a, T> {
|
||||
pub filters: UserMediaFileFilters<'a>,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Legacy numbered pagination needs an exact total. Count only this user's rows,
|
||||
/// without related-table lookups or global low-selectivity index intersections.
|
||||
pub async fn count_media_files_for_user<'c, T>(args: CountMediaFilesForUserArgs<'_, T>) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let mut query =
|
||||
QueryBuilder::<MySql>::new("SELECT COUNT(*) FROM media_files m FORCE INDEX (fk_maybe_creator_user_token)");
|
||||
args.filters.push_predicates(&mut query);
|
||||
query.build_query_scalar().fetch_one(args.mysql_executor).await
|
||||
}
|
||||
+49
-438
@@ -1,191 +1,10 @@
|
||||
use std::collections::HashSet;
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
|
||||
use super::user_media_file_filters::UserMediaFileFilters;
|
||||
use super::user_media_file_list_item::{MediaFileListItem, MediaFileListItemInternal};
|
||||
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
|
||||
|
||||
pub struct MediaFileListPage {
|
||||
pub records: Vec<MediaFileListItem>,
|
||||
|
||||
pub sort_ascending: bool,
|
||||
|
||||
pub current_page: usize,
|
||||
pub total_page_count: usize,
|
||||
}
|
||||
|
||||
pub struct MediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
pub maybe_origin_model_title: Option<String>,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub is_user_upload: bool,
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
pub maybe_prompt_args: Option<PromptInnerPayload>,
|
||||
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListMediaFileForUserArgs<'a> {
|
||||
pub username: &'a str,
|
||||
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
|
||||
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
|
||||
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
|
||||
pub include_user_uploads: bool,
|
||||
pub page_size: usize,
|
||||
pub page_index: usize,
|
||||
pub sort_ascending: bool,
|
||||
pub view_as: ViewAs,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> AnyhowResult<MediaFileListPage> {
|
||||
/// Let's figure out how many results we could have returned total
|
||||
let count_fields = select_total_count_field();
|
||||
let mut count_query_builder = query_builder(
|
||||
args.maybe_filter_media_types,
|
||||
args.maybe_filter_media_classes,
|
||||
args.maybe_filter_engine_categories,
|
||||
args.include_user_uploads,
|
||||
args.username,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
count_fields.as_str(),
|
||||
true,
|
||||
);
|
||||
|
||||
let row_count_query = count_query_builder.build_query_scalar::<i64>();
|
||||
let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
|
||||
|
||||
/// Now fetch the actual results with all the fields
|
||||
let result_fields = select_result_fields();
|
||||
let mut query = query_builder(
|
||||
args.maybe_filter_media_types,
|
||||
args.maybe_filter_media_classes,
|
||||
args.maybe_filter_engine_categories,
|
||||
args.include_user_uploads,
|
||||
args.username,
|
||||
true,
|
||||
args.page_index,
|
||||
args.page_size,
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
result_fields.as_str(),
|
||||
false,
|
||||
);
|
||||
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|record| {
|
||||
MediaFileListItem {
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
maybe_origin_model_title: record.maybe_origin_model_title,
|
||||
public_bucket_directory_hash: record.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: i8_to_bool(record.is_user_upload),
|
||||
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
|
||||
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
|
||||
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
|
||||
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_prompt_args: record.maybe_other_prompt_args
|
||||
.as_deref()
|
||||
.map(|args| PromptInnerPayload::from_json(args))
|
||||
.transpose()
|
||||
.ok() // NB: Fail open
|
||||
.flatten(),
|
||||
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
|
||||
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: record.maybe_bookmark_count,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(MediaFileListPage {
|
||||
records: results,
|
||||
sort_ascending: args.sort_ascending,
|
||||
current_page: args.page_index,
|
||||
total_page_count: number_of_pages,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_result_fields() -> String {
|
||||
r#"
|
||||
const RESULT_FIELDS: &str = r#"
|
||||
m.id,
|
||||
m.token,
|
||||
|
||||
@@ -229,258 +48,50 @@ fn select_result_fields() -> String {
|
||||
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
"#
|
||||
.to_string()
|
||||
"#;
|
||||
|
||||
pub struct ListMediaFileForUserArgs<'a, T> {
|
||||
pub filters: UserMediaFileFilters<'a>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
pub sort_ascending: bool,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
fn select_total_count_field() -> String {
|
||||
r#"
|
||||
COUNT(m.id) AS total_count
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn query_builder<'a>(
|
||||
maybe_filter_media_types: Option<&HashSet<MediaFileType>>,
|
||||
maybe_filter_media_classes: Option<&HashSet<MediaFileClass>>,
|
||||
maybe_filter_engine_categories: Option<&HashSet<MediaFileEngineCategory>>,
|
||||
include_user_uploads: bool,
|
||||
username: &'a str,
|
||||
enforce_limits: bool,
|
||||
page_index: usize,
|
||||
page_size: usize,
|
||||
sort_ascending: bool,
|
||||
view_as: ViewAs,
|
||||
select_fields: &'a str,
|
||||
count_only: bool,
|
||||
) -> QueryBuilder<'a, MySql> {
|
||||
|
||||
// NB: The count query only needs the `users` join (for the username predicate).
|
||||
// The other joins are all 1:1 and contribute nothing to the count — MySQL does
|
||||
// not eliminate LEFT JOINs, so including them costs four index lookups per row.
|
||||
let from_clause = if count_only {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
"#
|
||||
} else {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
LEFT OUTER JOIN model_weights as w
|
||||
ON m.maybe_origin_model_token = w.token
|
||||
LEFT OUTER JOIN media_files as media_file_cover_image
|
||||
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
LEFT OUTER JOIN entity_stats
|
||||
ON entity_stats.entity_type = "media_file"
|
||||
AND entity_stats.entity_token = m.token
|
||||
LEFT OUTER JOIN prompts
|
||||
ON prompts.token = m.maybe_prompt_token
|
||||
"#
|
||||
};
|
||||
|
||||
// NB: Query cannot be statically checked by sqlx
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
|
||||
format!(r#"
|
||||
SELECT
|
||||
{select_fields}
|
||||
{from_clause}"#
|
||||
));
|
||||
|
||||
query_builder.push(" WHERE u.username = ");
|
||||
query_builder.push_bind(username);
|
||||
|
||||
//if let Some(media_type) = maybe_filter_media_type {
|
||||
// // FIXME: Binding shouldn't require to_str().
|
||||
// // Otherwise, it's calling the Display trait on the raw type which is resulting in an
|
||||
// // incorrect binding and runtime error.
|
||||
// query_builder.push(" AND m.media_type = ");
|
||||
// query_builder.push_bind(media_type.to_str());
|
||||
//}
|
||||
|
||||
if let Some(media_types) = maybe_filter_media_types {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !media_types.is_empty() {
|
||||
query_builder.push(" AND m.media_type IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for media_type in media_types.iter() {
|
||||
separated.push_bind(media_type.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(media_classes) = maybe_filter_media_classes {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !media_classes.is_empty() {
|
||||
query_builder.push(" AND m.media_class IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for media_class in media_classes.iter() {
|
||||
separated.push_bind(media_class.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(engine_categories) = maybe_filter_engine_categories {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !engine_categories.is_empty() {
|
||||
query_builder.push(" AND m.maybe_engine_category IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for engine_category in engine_categories.iter() {
|
||||
separated.push_bind(engine_category.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if !include_user_uploads {
|
||||
query_builder.push(" AND NOT m.is_user_upload ");
|
||||
}
|
||||
|
||||
// TODO: Argument to control
|
||||
query_builder.push(" AND NOT m.is_intermediate_system_file ");
|
||||
|
||||
match view_as {
|
||||
ViewAs::Author | ViewAs::Moderator => {
|
||||
// NB(bt): Actually, mods don't want to see deleted files. We'll improve the moderator UI later.
|
||||
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
|
||||
}
|
||||
ViewAs::AnotherUser => {
|
||||
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
|
||||
// FIXME: Binding shouldn't require to_str().
|
||||
// Otherwise, it's calling the Display trait on the raw type which is resulting in an
|
||||
// incorrect binding and runtime error.
|
||||
query_builder.push(" AND m.creator_set_visibility = ");
|
||||
query_builder.push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Ordering is meaningless for the single-row COUNT aggregate.
|
||||
if !count_only {
|
||||
if sort_ascending {
|
||||
query_builder.push(" ORDER BY m.created_at ASC ");
|
||||
} else {
|
||||
query_builder.push(" ORDER BY m.created_at DESC ");
|
||||
}
|
||||
}
|
||||
|
||||
if enforce_limits {
|
||||
let offset = page_index * page_size;
|
||||
query_builder.push(format!(" LIMIT {page_size} OFFSET {offset} "));
|
||||
}
|
||||
|
||||
query_builder
|
||||
}
|
||||
|
||||
struct MediaFileListItemInternal {
|
||||
id: i64,
|
||||
token: MediaFileToken,
|
||||
|
||||
media_class: MediaFileClass,
|
||||
media_type: MediaFileType,
|
||||
|
||||
maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
origin_category: MediaFileOriginCategory,
|
||||
origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
maybe_origin_model_title: Option<String>,
|
||||
maybe_other_prompt_args: Option<String>,
|
||||
maybe_duration_millis: Option<i32>,
|
||||
|
||||
public_bucket_directory_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
is_user_upload: i8,
|
||||
is_intermediate_system_file: i8,
|
||||
|
||||
maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
maybe_text_transcript: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
|
||||
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
|
||||
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
|
||||
// cannot have type hints, eg. the following:
|
||||
//
|
||||
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
|
||||
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
//
|
||||
// This results in the automatic mapping not being able to be found by name (for macro derive), and
|
||||
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
|
||||
// full "as" clause).
|
||||
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: MediaFileToken::new(row.try_get("token")?),
|
||||
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
|
||||
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
|
||||
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
|
||||
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
|
||||
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
|
||||
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
|
||||
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
|
||||
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
|
||||
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
|
||||
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
|
||||
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
|
||||
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
is_user_upload: row.try_get("is_user_upload")?,
|
||||
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
|
||||
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
|
||||
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
|
||||
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
|
||||
maybe_title: row.try_get("maybe_title")?,
|
||||
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
/// Select a bounded page before joining potentially large prompt/metadata rows.
|
||||
/// `LIMIT` prevents MySQL from merging the derived table into the outer query.
|
||||
pub async fn list_media_files_for_user<'c, T>(
|
||||
args: ListMediaFileForUserArgs<'_, T>,
|
||||
) -> Result<Vec<MediaFileListItem>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let order = if args.sort_ascending { " ASC" } else { " DESC" };
|
||||
let mut query = QueryBuilder::<MySql>::new(format!("SELECT {RESULT_FIELDS} FROM (SELECT m.id "));
|
||||
// This index is in the July 2026 migrations. Restricting the scan to this user
|
||||
// avoids index-merge scans of the global deletion/class indexes on large tables.
|
||||
query.push("FROM media_files m FORCE INDEX (idx_creator_created_at) ");
|
||||
args.filters.push_predicates(&mut query);
|
||||
query.push(" ORDER BY m.created_at").push(order).push(", m.id").push(order);
|
||||
query.push(" LIMIT ").push_bind(args.limit as u64);
|
||||
query.push(" OFFSET ").push_bind(args.offset as u64);
|
||||
query.push(
|
||||
r#") AS page
|
||||
JOIN media_files m ON m.id = page.id
|
||||
LEFT JOIN model_weights w ON m.maybe_origin_model_token = w.token
|
||||
LEFT JOIN media_files media_file_cover_image ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
LEFT JOIN entity_stats ON entity_stats.entity_type = "#,
|
||||
);
|
||||
query.push_bind(StatsEntityType::MediaFile.to_str());
|
||||
query
|
||||
.push(
|
||||
r#" AND entity_stats.entity_token = m.token
|
||||
LEFT JOIN prompts ON prompts.token = m.maybe_prompt_token
|
||||
ORDER BY m.created_at"#,
|
||||
)
|
||||
.push(order)
|
||||
.push(", m.id")
|
||||
.push(order);
|
||||
let records = query.build_query_as::<MediaFileListItemInternal>().fetch_all(args.mysql_executor).await?;
|
||||
Ok(records.into_iter().map(MediaFileListItem::from).collect())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod count_media_files_for_user;
|
||||
pub mod list_batch_generated_redux_media_files;
|
||||
pub mod list_featured_media_files;
|
||||
pub mod list_media_files;
|
||||
@@ -7,3 +8,5 @@ pub mod list_media_files_for_user;
|
||||
pub mod list_session_media_files_by_class;
|
||||
pub mod list_session_project_media_files;
|
||||
pub mod media_file_list_row;
|
||||
pub mod user_media_file_filters;
|
||||
pub mod user_media_file_list_item;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use sqlx::{MySql, QueryBuilder};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
|
||||
/// Shared predicates keep the optional total and the returned page consistent.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct UserMediaFileFilters<'a> {
|
||||
pub username: &'a str,
|
||||
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
|
||||
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
|
||||
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
|
||||
pub include_user_uploads: bool,
|
||||
pub view_as: ViewAs,
|
||||
}
|
||||
|
||||
impl<'a> UserMediaFileFilters<'a> {
|
||||
pub(super) fn push_predicates(&self, query: &mut QueryBuilder<'a, MySql>) {
|
||||
// Resolve the unique username once; subsequent scans are scoped to its token.
|
||||
query.push(" WHERE m.maybe_creator_user_token = (SELECT token FROM users WHERE username = ");
|
||||
query.push_bind(self.username).push(")");
|
||||
if let Some(values) = self.maybe_filter_media_types.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.media_type IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if let Some(values) = self.maybe_filter_media_classes.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.media_class IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if let Some(values) = self.maybe_filter_engine_categories.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.maybe_engine_category IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if !self.include_user_uploads {
|
||||
query.push(" AND NOT m.is_user_upload");
|
||||
}
|
||||
query.push(" AND NOT m.is_intermediate_system_file AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL");
|
||||
if self.view_as == ViewAs::AnotherUser {
|
||||
query.push(" AND m.creator_set_visibility = ").push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
|
||||
|
||||
pub struct MediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
pub maybe_origin_model_title: Option<String>,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub is_user_upload: bool,
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
pub maybe_prompt_args: Option<PromptInnerPayload>,
|
||||
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub(super) struct MediaFileListItemInternal {
|
||||
id: i64,
|
||||
token: MediaFileToken,
|
||||
|
||||
media_class: MediaFileClass,
|
||||
media_type: MediaFileType,
|
||||
|
||||
maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
origin_category: MediaFileOriginCategory,
|
||||
origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
maybe_origin_model_title: Option<String>,
|
||||
maybe_other_prompt_args: Option<String>,
|
||||
maybe_duration_millis: Option<i32>,
|
||||
|
||||
public_bucket_directory_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
is_user_upload: i8,
|
||||
is_intermediate_system_file: i8,
|
||||
|
||||
maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
maybe_text_transcript: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
|
||||
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
|
||||
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
|
||||
// cannot have type hints, eg. the following:
|
||||
//
|
||||
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
|
||||
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
//
|
||||
// This results in the automatic mapping not being able to be found by name (for macro derive), and
|
||||
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
|
||||
// full "as" clause).
|
||||
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: MediaFileToken::new(row.try_get("token")?),
|
||||
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
|
||||
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
|
||||
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
|
||||
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
|
||||
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
|
||||
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
|
||||
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
|
||||
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
|
||||
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
|
||||
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
|
||||
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
|
||||
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
is_user_upload: row.try_get("is_user_upload")?,
|
||||
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
|
||||
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
|
||||
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
|
||||
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
|
||||
maybe_title: row.try_get("maybe_title")?,
|
||||
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MediaFileListItemInternal> for MediaFileListItem {
|
||||
fn from(record: MediaFileListItemInternal) -> Self {
|
||||
MediaFileListItem {
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
maybe_origin_model_title: record.maybe_origin_model_title,
|
||||
public_bucket_directory_hash: record.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: i8_to_bool(record.is_user_upload),
|
||||
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
|
||||
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
|
||||
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
|
||||
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_prompt_args: record.maybe_other_prompt_args
|
||||
.as_deref()
|
||||
.map(|args| PromptInnerPayload::from_json(args))
|
||||
.transpose()
|
||||
.ok() // NB: Fail open
|
||||
.flatten(),
|
||||
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
|
||||
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: record.maybe_bookmark_count,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ pub mod unsubscribe_reason;
|
||||
pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_referrals;
|
||||
pub mod user_daily_spends;
|
||||
@@ -52,4 +53,4 @@ pub mod voice_conversion;
|
||||
pub mod voice_designer;
|
||||
pub mod wallet_ledger_entries;
|
||||
pub mod wallets;
|
||||
pub mod web_referrals;
|
||||
pub mod web_referrals;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
|
||||
pub struct CreateChallengeArgs<'a, T> {
|
||||
pub token: &'a UserLoginChallengeToken,
|
||||
pub approval_hash: &'a [u8],
|
||||
pub device_hash: &'a [u8],
|
||||
pub confirmation_code: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn create_challenge<'c, T>(args: CreateChallengeArgs<'_, T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_login_challenges
|
||||
(token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
args.approval_hash,
|
||||
args.device_hash,
|
||||
args.confirmation_code,
|
||||
args.ip_address,
|
||||
args.status.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct DecideChallengeArgs<'a, T> {
|
||||
pub challenge_id: u64,
|
||||
pub user_token: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub approve: bool,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn decide_challenge<'c, T>(args: DecideChallengeArgs<'_, T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let status = if args.approve { UserLoginChallengeStatus::Approved } else { UserLoginChallengeStatus::Failed };
|
||||
let failure = if args.approve { None } else { Some(UserLoginChallengeFailureType::UserDeclined) };
|
||||
let failure_ip = if args.approve { None } else { Some(args.ip_address) };
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,
|
||||
maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,
|
||||
maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())
|
||||
WHERE id = ? AND status = ? AND expires_at > NOW()
|
||||
"#,
|
||||
status.to_str(),
|
||||
args.user_token,
|
||||
args.ip_address,
|
||||
failure.map(|value| value.to_str()),
|
||||
failure_ip,
|
||||
args.approve,
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Pending.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct ExpireAbandonedChallengesArgs<T> {
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Bounded sweep also audits abandoned attempts that are never polled again.
|
||||
pub async fn expire_abandoned_challenges<'c, T>(args: ExpireAbandonedChallengesArgs<T>) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at
|
||||
WHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000
|
||||
"#,
|
||||
UserLoginChallengeStatus::Failed.to_str(),
|
||||
UserLoginChallengeFailureType::Expired.to_str(),
|
||||
UserLoginChallengeStatus::Pending.to_str(),
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct ExpireChallengeArgs<T> {
|
||||
pub challenge_id: u64,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Audit expiration of a pending/approved challenge in the caller's transaction.
|
||||
pub async fn expire_challenge<'c, T>(args: ExpireChallengeArgs<T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at
|
||||
WHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()
|
||||
"#,
|
||||
UserLoginChallengeStatus::Failed.to_str(),
|
||||
UserLoginChallengeFailureType::Expired.to_str(),
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Pending.to_str(),
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct IsChallengeExpiredArgs<T> {
|
||||
pub challenge_id: u64,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Run AFTER acquiring the row lock: NOW() on the locking statement can precede
|
||||
/// time spent waiting for another transaction. Never extend the original deadline.
|
||||
pub async fn is_challenge_expired<'c, T>(args: IsChallengeExpiredArgs<T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?
|
||||
"#,
|
||||
args.challenge_id
|
||||
)
|
||||
.fetch_one(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(result.expired != 0)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::login_challenge::LoginChallenge;
|
||||
|
||||
pub struct LockByApprovalArgs<'a, T> {
|
||||
pub approval_hash: &'a [u8],
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Use the caller's transaction to retain the row lock for subsequent decisions.
|
||||
/// The approval token cannot be used in device lookups.
|
||||
pub async fn lock_by_approval<'c, T>(args: LockByApprovalArgs<'_, T>) -> Result<Option<LoginChallenge>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
args.approval_hash
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::login_challenge::LoginChallenge;
|
||||
|
||||
pub struct LockByDeviceArgs<'a, T> {
|
||||
pub device_hash: &'a [u8],
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Use the caller's transaction to retain the row lock throughout redemption.
|
||||
pub async fn lock_by_device<'c, T>(args: LockByDeviceArgs<'_, T>) -> Result<Option<LoginChallenge>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
args.device_hash
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
|
||||
pub struct LoginChallenge {
|
||||
pub id: u64,
|
||||
pub confirmation_code: String,
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub maybe_deciding_user_token: Option<String>,
|
||||
pub maybe_redeemed_user_session_id: Option<i64>,
|
||||
pub maybe_failure_type: Option<UserLoginChallengeFailureType>,
|
||||
pub ip_address_creation: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct MarkRedeemedArgs<'a, T> {
|
||||
pub challenge_id: u64,
|
||||
pub session_id: i64,
|
||||
pub ip_address: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn mark_redeemed<'c, T>(args: MarkRedeemedArgs<'_, T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,
|
||||
maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()
|
||||
WHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL
|
||||
"#,
|
||||
UserLoginChallengeStatus::Redeemed.to_str(),
|
||||
args.session_id,
|
||||
args.ip_address,
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod create_challenge;
|
||||
pub mod decide_challenge;
|
||||
pub mod expire_abandoned_challenges;
|
||||
pub mod expire_challenge;
|
||||
pub mod is_challenge_expired;
|
||||
pub mod lock_by_approval;
|
||||
pub mod lock_by_device;
|
||||
pub mod login_challenge;
|
||||
pub mod mark_redeemed;
|
||||
@@ -0,0 +1,6 @@
|
||||
pub struct BridgeSession {
|
||||
pub id: i64,
|
||||
pub token: String,
|
||||
pub user_token: String,
|
||||
pub username: String,
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
|
||||
pub struct CreateBridgeSessionArgs<'a, T> {
|
||||
pub user_token: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Returns the inserted session ID, or None if the user cannot sign in.
|
||||
/// Must share the transaction holding the challenge lock. A failed/expired
|
||||
/// redemption rolls back this INSERT; no session is minted when merely approving.
|
||||
pub async fn create_bridge_session<'c, T>(args: CreateBridgeSessionArgs<'_, T>) -> Result<Option<i64>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = UserSessionToken::generate();
|
||||
let inserted = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)
|
||||
SELECT ?, token, ?, ?, NOW() + INTERVAL 1 YEAR FROM users
|
||||
WHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.ip_address,
|
||||
UserWebSessionCreationType::DeviceApproval.to_str(),
|
||||
args.user_token
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
if inserted.rows_affected() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(inserted.last_insert_id() as i64))
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
@@ -36,14 +37,16 @@ INSERT INTO user_sessions (
|
||||
user_token,
|
||||
maybe_impersonation_user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
args.user_token.as_str(),
|
||||
args.impersonator_user_token.as_str(),
|
||||
args.ip_address,
|
||||
UserWebSessionCreationType::Impersonation.to_str(),
|
||||
args.expires_at,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@@ -17,13 +18,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token,
|
||||
user_token.to_string(),
|
||||
ip_address.to_string(),
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
)
|
||||
.execute(mysql_pool)
|
||||
.await;
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
@@ -19,13 +20,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
ip_address,
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
)
|
||||
.execute(mysql_executor)
|
||||
.await?;
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@@ -22,13 +23,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
ip_address,
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
);
|
||||
|
||||
let query_result = transactor.execute(query).await;
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::bridge_session::BridgeSession;
|
||||
|
||||
pub struct FindApprovingSessionArgs<'a, T> {
|
||||
pub session_token: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Fresh database lookup, not a cached session. Impersonated sessions cannot consent.
|
||||
pub async fn find_approving_session<'c, T>(args: FindApprovingSessionArgs<'_, T>) -> Result<Option<BridgeSession>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
BridgeSession,
|
||||
r#"
|
||||
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
|
||||
WHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()
|
||||
AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE
|
||||
AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
|
||||
"#,
|
||||
args.session_token
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::bridge_session::BridgeSession;
|
||||
|
||||
pub struct FindRedeemedSessionArgs<'a, T> {
|
||||
pub session_id: i64,
|
||||
pub user_token: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Returns the one previously minted session, only while it and its owner are live.
|
||||
pub async fn find_redeemed_session<'c, T>(args: FindRedeemedSessionArgs<'_, T>) -> Result<Option<BridgeSession>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
BridgeSession,
|
||||
r#"
|
||||
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
|
||||
WHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = ?
|
||||
AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL
|
||||
AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
|
||||
"#,
|
||||
args.session_id,
|
||||
args.user_token,
|
||||
UserWebSessionCreationType::DeviceApproval.to_str()
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+1
@@ -169,6 +169,7 @@ LEFT OUTER JOIN user_roles
|
||||
ON users.user_role_slug = user_roles.slug
|
||||
WHERE user_sessions.token = ?
|
||||
AND user_sessions.deleted_at IS NULL
|
||||
AND user_sessions.expires_at > NOW()
|
||||
AND users.user_deleted_at IS NULL
|
||||
AND users.mod_deleted_at IS NULL
|
||||
"#,
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ SELECT
|
||||
FROM user_sessions
|
||||
WHERE token = ?
|
||||
AND deleted_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
session_token,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
pub mod bridge_session;
|
||||
pub mod create_bridge_session;
|
||||
pub mod create_impersonated_user_session;
|
||||
pub mod create_user_session;
|
||||
pub mod create_user_session_with_executor;
|
||||
pub mod create_user_session_with_transactor;
|
||||
pub mod delete_user_session;
|
||||
pub mod find_approving_session;
|
||||
pub mod find_redeemed_session;
|
||||
pub mod get_user_session_by_token;
|
||||
pub mod get_user_session_by_token_light;
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Mutators/inspection for disposable bridge integration databases only.
|
||||
use sqlx::{MySqlPool, Row};
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use super::users::{create_test_user, TestUser};
|
||||
|
||||
pub struct ChallengeAudit {
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub failure: Option<UserLoginChallengeFailureType>,
|
||||
pub creation_ip: String,
|
||||
pub failure_ip: Option<String>,
|
||||
pub session_id: Option<i64>,
|
||||
pub lifetime_seconds: i64,
|
||||
pub deciding_user: Option<String>,
|
||||
pub decision_ip: Option<String>,
|
||||
pub redemption_ip: Option<String>,
|
||||
pub decided_at: Option<DateTime<Utc>>,
|
||||
pub redeemed_at: Option<DateTime<Utc>>,
|
||||
pub failed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub async fn create_passwordless_user(pool: &MySqlPool, flags: &[UserFeatureFlag]) -> TestUser {
|
||||
let user = create_test_user(pool).await.expect("create passwordless fixture");
|
||||
set_passwordless(pool, &user.user_token).await;
|
||||
let flags = flags.iter().map(UserFeatureFlag::to_str).collect::<Vec<_>>().join(",");
|
||||
sqlx::query("UPDATE users SET maybe_feature_flags = ? WHERE token = ?")
|
||||
.bind(flags).bind(user.user_token.as_str()).execute(pool).await.expect("set fixture feature flags");
|
||||
user
|
||||
}
|
||||
|
||||
pub async fn audit(pool: &MySqlPool, device_hash: &[u8]) -> ChallengeAudit {
|
||||
let row = sqlx::query("SELECT status, maybe_failure_type, ip_address_creation, maybe_ip_address_failure, maybe_redeemed_user_session_id, TIMESTAMPDIFF(SECOND, created_at, expires_at) AS lifetime, maybe_deciding_user_token, maybe_ip_address_decision, maybe_ip_address_redemption, maybe_decided_at, maybe_redeemed_at, maybe_failed_at FROM user_login_challenges WHERE device_token_sha256 = ?").bind(device_hash).fetch_one(pool).await.expect("challenge audit");
|
||||
ChallengeAudit {
|
||||
status: row.get("status"), failure: row.get("maybe_failure_type"), creation_ip: row.get("ip_address_creation"),
|
||||
failure_ip: row.get("maybe_ip_address_failure"), session_id: row.get("maybe_redeemed_user_session_id"), lifetime_seconds: row.get("lifetime"),
|
||||
deciding_user: row.get("maybe_deciding_user_token"), decision_ip: row.get("maybe_ip_address_decision"),
|
||||
redemption_ip: row.get("maybe_ip_address_redemption"), decided_at: row.get("maybe_decided_at"),
|
||||
redeemed_at: row.get("maybe_redeemed_at"), failed_at: row.get("maybe_failed_at"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bridge_session_count(pool: &MySqlPool) -> i64 {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE maybe_creation_type = ?")
|
||||
.bind(UserWebSessionCreationType::DeviceApproval.to_str()).fetch_one(pool).await.expect("bridge session count")
|
||||
}
|
||||
|
||||
pub async fn assert_challenge_status_is_explicit(pool: &MySqlPool) {
|
||||
let column = sqlx::query("SELECT COLUMN_DEFAULT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_login_challenges' AND COLUMN_NAME = 'status'")
|
||||
.fetch_one(pool).await.expect("inspect migrated status column");
|
||||
assert!(column.get::<Option<String>, _>("COLUMN_DEFAULT").is_none());
|
||||
assert_eq!(column.get::<String, _>("IS_NULLABLE"), "NO");
|
||||
|
||||
// Every other required field is supplied; MySQL must reject the omitted status.
|
||||
let token = UserLoginChallengeToken::generate();
|
||||
let error = sqlx::query("INSERT INTO user_login_challenges (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, expires_at) VALUES (?, ?, ?, ?, ?, NOW() + INTERVAL 20 MINUTE)")
|
||||
.bind(token.as_str()).bind(&[0u8; 32][..]).bind(&[1u8; 32][..])
|
||||
.bind("BCDFGHJK").bind("192.0.2.1").execute(pool).await.expect_err("status must be explicit");
|
||||
let error = error.as_database_error().expect("MySQL missing-column error");
|
||||
assert_eq!(error.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>().unwrap().number(), 1364);
|
||||
}
|
||||
|
||||
pub async fn set_challenge_deadline(pool: &MySqlPool, device_hash: &[u8], seconds_from_now: i32) {
|
||||
sqlx::query("UPDATE user_login_challenges SET expires_at = TIMESTAMPADD(SECOND, ?, NOW()) WHERE device_token_sha256 = ?").bind(seconds_from_now).bind(device_hash).execute(pool).await.expect("change test deadline");
|
||||
}
|
||||
|
||||
pub async fn expire_session(pool: &MySqlPool, token: &str) {
|
||||
sqlx::query("UPDATE user_sessions SET expires_at = NOW() - INTERVAL 1 SECOND WHERE token = ?").bind(token).execute(pool).await.expect("expire test session");
|
||||
}
|
||||
|
||||
pub async fn revoke_session(pool: &MySqlPool, token: &str) {
|
||||
sqlx::query("UPDATE user_sessions SET deleted_at = NOW() WHERE token = ?").bind(token).execute(pool).await.expect("revoke test session");
|
||||
}
|
||||
|
||||
pub async fn set_passwordless(pool: &MySqlPool, user: &UserToken) {
|
||||
sqlx::query("UPDATE users SET is_without_password = TRUE, email_confirmed_by_google = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("passwordless test account");
|
||||
}
|
||||
|
||||
pub async fn ban_user(pool: &MySqlPool, user: &UserToken) {
|
||||
sqlx::query("UPDATE users SET is_banned = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("ban test account");
|
||||
}
|
||||
|
||||
pub async fn impersonate_session(pool: &MySqlPool, token: &str, actor: &UserToken) {
|
||||
sqlx::query("UPDATE user_sessions SET maybe_impersonation_user_token = ? WHERE token = ?").bind(actor.as_str()).bind(token).execute(pool).await.expect("test impersonation");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod mcp_sessions;
|
||||
pub mod login_challenges;
|
||||
pub mod media_files;
|
||||
pub mod users;
|
||||
pub mod wallets;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Disposable databases on the local MySQL instance only. No application or test
|
||||
//! database URL is read, so production configuration cannot redirect these tests.
|
||||
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
use sqlx::MySqlPool;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::schema::ensure_schema;
|
||||
|
||||
pub struct IsolatedTestDatabase {
|
||||
pub pool: MySqlPool,
|
||||
admin_pool: MySqlPool,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl IsolatedTestDatabase {
|
||||
pub async fn create() -> Self {
|
||||
// Deliberately no URL/env parsing, DNS, TCP (including forwarded local ports),
|
||||
// or socket overrides. This is the local Homebrew MySQL socket. Only a new
|
||||
// randomly named database can be created/dropped; existing schemas are unused.
|
||||
let options = MySqlConnectOptions::new().socket("/tmp/mysql.sock").username("root").ssl_mode(MySqlSslMode::Disabled);
|
||||
let admin_pool = MySqlPoolOptions::new().max_connections(1).connect_with(options.clone()).await.expect("local MySQL test connection");
|
||||
let name = format!("artcraft_test_bridge_{}", UserToken::generate().as_str());
|
||||
assert!(name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
|
||||
sqlx::query(&format!("CREATE DATABASE `{name}`")).execute(&admin_pool).await.expect("create disposable database");
|
||||
let pool = MySqlPoolOptions::new().max_connections(8).connect_with(options.database(&name)).await.expect("connect disposable database");
|
||||
ensure_schema(&pool).await;
|
||||
Self { pool, admin_pool, name }
|
||||
}
|
||||
|
||||
pub async fn destroy(self) {
|
||||
self.pool.close().await;
|
||||
sqlx::query(&format!("DROP DATABASE `{}`", self.name)).execute(&self.admin_pool).await.expect("drop our disposable database");
|
||||
self.admin_pool.close().await;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
pub mod fixtures;
|
||||
pub mod guard;
|
||||
pub mod isolated;
|
||||
pub mod pool;
|
||||
pub mod schema;
|
||||
pub mod serial;
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Executes the actual library queries against migrated, disposable local MySQL.
|
||||
//! IsolatedTestDatabase uses a fixed Unix socket, never DATABASE_URL/MYSQL_URL.
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::media_files::list::count_media_files_for_user::{
|
||||
count_media_files_for_user, CountMediaFilesForUserArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
|
||||
list_media_files_for_user, ListMediaFileForUserArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use mysql_testing::fixtures::users::create_test_user;
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use sqlx::{MySql, MySqlPool, QueryBuilder};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
const CREATED_AT: &str = "2025-01-01 12:00:00";
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_media_library_preserves_filters_visibility_joins_and_pagination() {
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let owner = create_test_user(&db.pool).await.unwrap();
|
||||
let other = create_test_user(&db.pool).await.unwrap();
|
||||
let rows = [
|
||||
("m_public_image", MediaFileClass::Image, MediaFileType::Png, Visibility::Public, true),
|
||||
("m_private_video", MediaFileClass::Video, MediaFileType::Mp4, Visibility::Private, false),
|
||||
("m_hidden_audio", MediaFileClass::Audio, MediaFileType::Mp3, Visibility::Hidden, false),
|
||||
("m_public_mesh", MediaFileClass::Mesh, MediaFileType::Glb, Visibility::Public, false),
|
||||
("m_private_splat", MediaFileClass::Splat, MediaFileType::Spz, Visibility::Private, false),
|
||||
("m_legacy_mesh", MediaFileClass::Dimensional, MediaFileType::Glb, Visibility::Public, false),
|
||||
];
|
||||
for (token, class, kind, visibility, upload) in rows {
|
||||
insert_fixture(&db.pool, Fixture { owner: &owner.user_token, token, class, kind, visibility, upload }).await;
|
||||
}
|
||||
for token in ["m_intermediate", "m_user_deleted", "m_mod_deleted"] {
|
||||
insert_fixture(&db.pool, Fixture::image(&owner.user_token, token)).await;
|
||||
}
|
||||
insert_fixture(&db.pool, Fixture::image(&other.user_token, "m_other_user")).await;
|
||||
sqlx::query("UPDATE media_files SET is_intermediate_system_file = true WHERE token = 'm_intermediate'")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_user_deleted'")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE media_files SET mod_deleted_at = NOW() WHERE token = 'm_mod_deleted'")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE media_files SET maybe_engine_category = ? WHERE token = 'm_public_mesh'")
|
||||
.bind(MediaFileEngineCategory::Object.to_str())
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO prompts (token, prompt_type, creator_ip_address, maybe_other_args) VALUES ('p_library', ?, '127.0.0.1', ?)")
|
||||
.bind(PromptType::ArtcraftApp.to_str()).bind(r#"{"fd":true}"#).execute(&db.pool).await.unwrap();
|
||||
sqlx::query("UPDATE media_files SET maybe_cover_image_media_file_token = 'm_intermediate', maybe_prompt_token = 'p_library', maybe_duration_millis = 1234, maybe_title = 'Fixture title' WHERE token = 'm_private_video'").execute(&db.pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO entity_stats (entity_type, entity_token, ratings_positive_count, bookmark_count) VALUES (?, 'm_private_video', 3, 7)")
|
||||
.bind(StatsEntityType::MediaFile.to_str()).execute(&db.pool).await.unwrap();
|
||||
|
||||
let filters = filters(&owner.username);
|
||||
for (view_as, expected) in [
|
||||
(
|
||||
ViewAs::Author,
|
||||
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
|
||||
),
|
||||
(
|
||||
ViewAs::Moderator,
|
||||
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
|
||||
),
|
||||
(ViewAs::AnotherUser, vec!["m_legacy_mesh", "m_public_mesh", "m_public_image"]),
|
||||
] {
|
||||
let selected = UserMediaFileFilters { view_as, ..filters };
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters: selected,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(records.iter().map(|r| r.token.as_str()).collect::<Vec<_>>(), expected);
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
|
||||
.await
|
||||
.unwrap(),
|
||||
expected.len() as i64
|
||||
);
|
||||
}
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: true,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(records.first().unwrap().token.as_str(), "m_public_image");
|
||||
let video = &records[1];
|
||||
assert_eq!(video.maybe_file_cover_image_public_bucket_hash.as_deref(), Some("fixture_hash"));
|
||||
assert_eq!(video.maybe_prompt_args.as_ref().unwrap().used_face_detailer, Some(true));
|
||||
assert_eq!(video.maybe_ratings_positive_count, Some(3));
|
||||
assert_eq!(video.maybe_bookmark_count, Some(7));
|
||||
assert_eq!(video.maybe_duration_millis, Some(1234));
|
||||
assert_eq!(video.maybe_title.as_deref(), Some("Fixture title"));
|
||||
|
||||
// All timestamps tie. The id tiebreaker must avoid duplicates/omissions.
|
||||
let mut paged = vec![];
|
||||
for offset in [0, 2, 4, 6] {
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 2,
|
||||
offset,
|
||||
sort_ascending: true,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
paged.extend(records.into_iter().map(|r| r.token.to_string()));
|
||||
}
|
||||
assert_eq!(paged, rows.iter().map(|r| r.0.to_string()).collect::<Vec<_>>());
|
||||
let classes = HashSet::from([MediaFileClass::Image, MediaFileClass::Video]);
|
||||
let types = HashSet::from([MediaFileType::Glb]);
|
||||
let engines = HashSet::from([MediaFileEngineCategory::Object]);
|
||||
let empty = HashSet::new();
|
||||
for (selected, expected) in [
|
||||
(UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters }, 2),
|
||||
(UserMediaFileFilters { maybe_filter_media_types: Some(&types), ..filters }, 2),
|
||||
(UserMediaFileFilters { maybe_filter_engine_categories: Some(&engines), ..filters }, 1),
|
||||
(UserMediaFileFilters { include_user_uploads: false, ..filters }, 5),
|
||||
(UserMediaFileFilters { maybe_filter_media_classes: Some(&empty), ..filters }, 6),
|
||||
(UserMediaFileFilters { username: "' OR 1=1 --", ..filters }, 0),
|
||||
] {
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters: selected,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &db.pool,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(records.len(), expected);
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
|
||||
.await
|
||||
.unwrap(),
|
||||
expected as i64
|
||||
);
|
||||
}
|
||||
// Queries must honor an existing transaction, including uncommitted changes.
|
||||
let mut tx = db.pool.begin().await.unwrap();
|
||||
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_private_video'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *tx }).await.unwrap(),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &mut *tx
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
5
|
||||
);
|
||||
tx.rollback().await.unwrap();
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &db.pool }).await.unwrap(),
|
||||
6
|
||||
);
|
||||
db.destroy().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "large local fixture; run explicitly with --ignored --nocapture"]
|
||||
async fn user_media_library_large_page_reads_are_bounded() {
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let owner = create_test_user(&db.pool).await.unwrap();
|
||||
let other = create_test_user(&db.pool).await.unwrap();
|
||||
// 60k owner rows, followed by 240k newer files from another account.
|
||||
for chunk in 0..300 {
|
||||
let mut query = QueryBuilder::<MySql>::new("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) ");
|
||||
query.push_values(0..1000, |mut row, index| {
|
||||
row
|
||||
.push_bind(format!("m_bench_{}", chunk * 1000 + index))
|
||||
.push_bind(MediaFileOriginCategory::Upload.to_str())
|
||||
.push_bind(MediaFileOriginProductCategory::Unknown.to_str())
|
||||
.push_bind(MediaFileType::Png.to_str())
|
||||
.push_bind(MediaFileClass::Image.to_str())
|
||||
.push_bind("0".repeat(64))
|
||||
.push_bind("fixture_hash")
|
||||
.push_bind("127.0.0.1")
|
||||
.push_bind(if chunk < 60 { owner.user_token.as_str() } else { other.user_token.as_str() })
|
||||
.push_bind(Visibility::Private.to_str())
|
||||
.push_bind(false)
|
||||
.push_bind(true)
|
||||
.push_bind(CREATED_AT);
|
||||
});
|
||||
query.build().execute(&db.pool).await.unwrap();
|
||||
}
|
||||
sqlx::query("ANALYZE TABLE media_files").execute(&db.pool).await.unwrap();
|
||||
let classes = HashSet::from([
|
||||
MediaFileClass::Image,
|
||||
MediaFileClass::Video,
|
||||
MediaFileClass::Audio,
|
||||
MediaFileClass::Dimensional,
|
||||
MediaFileClass::Mesh,
|
||||
MediaFileClass::Splat,
|
||||
]);
|
||||
let filters = UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters(&owner.username) };
|
||||
let mut conn = db.pool.acquire().await.unwrap();
|
||||
let before = scan_reads(&mut conn).await;
|
||||
let start = Instant::now();
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: 61,
|
||||
offset: 0,
|
||||
sort_ascending: false,
|
||||
mysql_executor: &mut *conn,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
let scanned = scan_reads(&mut conn).await - before;
|
||||
assert_eq!(records.len(), 61);
|
||||
assert_eq!(records.first().unwrap().token.as_str(), "m_bench_59999");
|
||||
assert!(scanned < 200, "page query scanned {scanned} sequential index entries");
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *conn }).await.unwrap(),
|
||||
60_000
|
||||
);
|
||||
println!(
|
||||
"300k fixtures / 60k owned: page={elapsed:?}, sequential index reads={scanned}; optional count={:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
drop(conn);
|
||||
db.destroy().await;
|
||||
}
|
||||
|
||||
struct Fixture<'a> {
|
||||
owner: &'a UserToken,
|
||||
token: &'a str,
|
||||
class: MediaFileClass,
|
||||
kind: MediaFileType,
|
||||
visibility: Visibility,
|
||||
upload: bool,
|
||||
}
|
||||
|
||||
impl<'a> Fixture<'a> {
|
||||
fn image(owner: &'a UserToken, token: &'a str) -> Self {
|
||||
Self {
|
||||
owner,
|
||||
token,
|
||||
class: MediaFileClass::Image,
|
||||
kind: MediaFileType::Png,
|
||||
visibility: Visibility::Public,
|
||||
upload: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filters(username: &str) -> UserMediaFileFilters<'_> {
|
||||
UserMediaFileFilters {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
view_as: ViewAs::Author,
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_fixture(pool: &MySqlPool, fixture: Fixture<'_>) {
|
||||
sqlx::query("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) VALUES (?, ?, ?, ?, ?, ?, 'fixture_hash', '127.0.0.1', ?, ?, false, ?, ?)")
|
||||
.bind(fixture.token).bind(MediaFileOriginCategory::Upload.to_str()).bind(MediaFileOriginProductCategory::Unknown.to_str())
|
||||
.bind(fixture.kind.to_str()).bind(fixture.class.to_str()).bind("0".repeat(64)).bind(fixture.owner.as_str())
|
||||
.bind(fixture.visibility.to_str()).bind(fixture.upload).bind(CREATED_AT).execute(pool).await.unwrap();
|
||||
}
|
||||
|
||||
async fn scan_reads(conn: &mut sqlx::MySqlConnection) -> u64 {
|
||||
let values: Vec<(String, String)> =
|
||||
sqlx::query_as("SHOW SESSION STATUS WHERE Variable_name IN ('Handler_read_next', 'Handler_read_prev')")
|
||||
.fetch_all(conn)
|
||||
.await
|
||||
.unwrap();
|
||||
values.into_iter().map(|(_, value)| value.parse::<u64>().unwrap()).sum()
|
||||
}
|
||||
@@ -24,7 +24,9 @@ pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod usages;
|
||||
pub mod user_bookmarks;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_ratings;
|
||||
pub mod user_sessions;
|
||||
pub mod user_spend_events;
|
||||
pub mod users;
|
||||
pub mod voice_conversion_models;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod user_login_challenge_failure_type;
|
||||
pub mod user_login_challenge_status;
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_login_challenges.maybe_failure_type` (VARCHAR(32)).
|
||||
///
|
||||
/// Terminal outcome for a failed device approval request. Transient transport/database errors and invalid credentials are not challenge outcomes.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserLoginChallengeFailureType {
|
||||
/// An authenticated user explicitly declined the request.
|
||||
#[serde(rename = "user_declined")]
|
||||
UserDeclined,
|
||||
|
||||
/// The 20-minute deadline passed before the challenge was redeemed.
|
||||
#[serde(rename = "expired")]
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeFailureType);
|
||||
impl_mysql_enum_coders!(UserLoginChallengeFailureType);
|
||||
impl_mysql_from_row!(UserLoginChallengeFailureType);
|
||||
|
||||
impl UserLoginChallengeFailureType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UserDeclined => "user_declined",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"user_declined" => Ok(Self::UserDeclined),
|
||||
"expired" => Ok(Self::Expired),
|
||||
_ => Err(format!("invalid UserLoginChallengeFailureType value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::UserDeclined, Self::Expired])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserLoginChallengeFailureType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserLoginChallengeFailureType, &str)] = &[(UserLoginChallengeFailureType::UserDeclined, "user_declined"), (UserLoginChallengeFailureType::Expired, "expired")];
|
||||
const MAX_STORED_LENGTH: usize = 32;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserLoginChallengeFailureType::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserLoginChallengeFailureType::all_variants();
|
||||
assert_eq!(variants.len(), UserLoginChallengeFailureType::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserLoginChallengeFailureType::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserLoginChallengeFailureType::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "USER_DECLINED"] {
|
||||
assert!(UserLoginChallengeFailureType::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserLoginChallengeFailureType>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_login_challenges.status` (VARCHAR(16)).
|
||||
///
|
||||
/// Persisted state of a device approval request. Always check expires_at independently; a pending or approved row can already be expired.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserLoginChallengeStatus {
|
||||
/// Waiting for an authenticated user's explicit decision.
|
||||
#[serde(rename = "pending")]
|
||||
Pending,
|
||||
|
||||
/// Approved for the deciding user; no session has been minted yet.
|
||||
#[serde(rename = "approved")]
|
||||
Approved,
|
||||
|
||||
/// Exactly one session has been minted. Retries must never mint another.
|
||||
#[serde(rename = "redeemed")]
|
||||
Redeemed,
|
||||
|
||||
/// Terminal failure, described by maybe_failure_type.
|
||||
#[serde(rename = "failed")]
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeStatus);
|
||||
impl_mysql_enum_coders!(UserLoginChallengeStatus);
|
||||
impl_mysql_from_row!(UserLoginChallengeStatus);
|
||||
|
||||
impl UserLoginChallengeStatus {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Redeemed => "redeemed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"approved" => Ok(Self::Approved),
|
||||
"redeemed" => Ok(Self::Redeemed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
_ => Err(format!("invalid UserLoginChallengeStatus value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::Pending, Self::Approved, Self::Redeemed, Self::Failed])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserLoginChallengeStatus;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserLoginChallengeStatus, &str)] = &[(UserLoginChallengeStatus::Pending, "pending"), (UserLoginChallengeStatus::Approved, "approved"), (UserLoginChallengeStatus::Redeemed, "redeemed"), (UserLoginChallengeStatus::Failed, "failed")];
|
||||
const MAX_STORED_LENGTH: usize = 16;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserLoginChallengeStatus::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserLoginChallengeStatus::all_variants();
|
||||
assert_eq!(variants.len(), UserLoginChallengeStatus::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserLoginChallengeStatus::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserLoginChallengeStatus::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "PENDING"] {
|
||||
assert!(UserLoginChallengeStatus::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserLoginChallengeStatus>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod user_web_session_creation_type;
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_sessions.maybe_creation_type` (VARCHAR(32)).
|
||||
///
|
||||
/// How a cookie-based user session was created; NULL in MySQL means unknown. This is provenance, not an authentication policy or an MCP/API session type.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserWebSessionCreationType {
|
||||
/// A normal password or SSO login/signup, including direct desktop password login.
|
||||
#[serde(rename = "direct_login")]
|
||||
DirectLogin,
|
||||
|
||||
/// A new session authorized through a user_login_challenges device approval.
|
||||
#[serde(rename = "device_approval")]
|
||||
DeviceApproval,
|
||||
|
||||
/// An explicitly consented staff impersonation; existing impersonation rules still apply.
|
||||
#[serde(rename = "impersonation")]
|
||||
Impersonation,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserWebSessionCreationType);
|
||||
impl_mysql_enum_coders!(UserWebSessionCreationType);
|
||||
impl_mysql_from_row!(UserWebSessionCreationType);
|
||||
|
||||
impl UserWebSessionCreationType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DirectLogin => "direct_login",
|
||||
Self::DeviceApproval => "device_approval",
|
||||
Self::Impersonation => "impersonation",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"direct_login" => Ok(Self::DirectLogin),
|
||||
"device_approval" => Ok(Self::DeviceApproval),
|
||||
"impersonation" => Ok(Self::Impersonation),
|
||||
_ => Err(format!("invalid UserWebSessionCreationType value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::DirectLogin, Self::DeviceApproval, Self::Impersonation])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserWebSessionCreationType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserWebSessionCreationType, &str)] = &[(UserWebSessionCreationType::DirectLogin, "direct_login"), (UserWebSessionCreationType::DeviceApproval, "device_approval"), (UserWebSessionCreationType::Impersonation, "impersonation")];
|
||||
const MAX_STORED_LENGTH: usize = 32;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserWebSessionCreationType::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserWebSessionCreationType::all_variants();
|
||||
assert_eq!(variants.len(), UserWebSessionCreationType::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserWebSessionCreationType::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserWebSessionCreationType::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "DIRECT_LOGIN"] {
|
||||
assert!(UserWebSessionCreationType::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserWebSessionCreationType>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ pub(crate) enum TokenPrefix {
|
||||
UserBookmark,
|
||||
UserReferralCode,
|
||||
UserSession,
|
||||
UserLoginChallenge,
|
||||
UserSpendEvent,
|
||||
UserSubscription,
|
||||
VoiceConversionModel,
|
||||
@@ -145,6 +146,7 @@ impl PrefixGenerator for TokenPrefix {
|
||||
Self::UserBookmark => "ub_",
|
||||
Self::UserReferralCode => "urc_",
|
||||
Self::UserSession => "session_",
|
||||
Self::UserLoginChallenge => "login_",
|
||||
Self::UserSpendEvent => "spend_",
|
||||
Self::UserSubscription => "subscription_", // NB: Previously "SUB:"
|
||||
Self::VoiceConversionModel => "vcm_",
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod user_bookmarks;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_sessions;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_spend_events;
|
||||
pub mod user_subscriptions;
|
||||
pub mod users;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// Non-secret identifier for a login challenge. This never grants access.
|
||||
#[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 UserLoginChallengeToken(pub String);
|
||||
|
||||
impl_string_token!(UserLoginChallengeToken);
|
||||
impl_mysql_token_from_row!(UserLoginChallengeToken);
|
||||
impl_crockford_generator!(UserLoginChallengeToken, 32usize, TokenPrefix::UserLoginChallenge, CrockfordLower);
|
||||
@@ -114,6 +114,7 @@ lexical-sort = "0.3.1"
|
||||
log.workspace = true
|
||||
once_cell.workspace = true
|
||||
rand.workspace = true
|
||||
sha2.workspace = true
|
||||
regex.workspace = true
|
||||
strum_macros = "0.26.4"
|
||||
time.workspace = true
|
||||
@@ -216,6 +217,7 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
test_utils = { path = "../../../testing/test_utils" }
|
||||
|
||||
# Testing/Dev Deps: External
|
||||
reqwest.workspace = true
|
||||
speculoos.workspace = true
|
||||
strum.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -59,6 +59,7 @@ use crate::http_server::endpoints::media_files::get::get_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::*;
|
||||
use crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::*;
|
||||
@@ -274,6 +275,7 @@ use crate::http_server::endpoints::users::edit_username_handler::*;
|
||||
use crate::http_server::endpoints::users::get_profile_handler::*;
|
||||
use crate::http_server::endpoints::users::google_sso::google_sso_handler::*;
|
||||
use artcraft_api_defs::users::login::{LoginRequest, LoginSuccessResponse, LoginErrorType};
|
||||
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse, DecideLoginChallengeRequest, LoginChallengeFailure, LoginChallengeResponse, LoginChallengeState, PollLoginChallengeRequest, ReviewLoginChallengeRequest, ReviewLoginChallengeResponse};
|
||||
use crate::http_server::endpoints::users::login_handler::LoginErrorResponse;
|
||||
use crate::http_server::endpoints::users::logout_handler::*;
|
||||
use crate::http_server::endpoints::users::session_info_handler::*;
|
||||
@@ -518,6 +520,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::list_media_files_for_user_v2_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler,
|
||||
@@ -570,6 +573,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::users::get_profile_handler::get_profile_handler,
|
||||
crate::http_server::endpoints::users::google_sso::google_sso_handler::google_sso_handler,
|
||||
crate::http_server::endpoints::users::login_handler::login_handler,
|
||||
crate::http_server::endpoints::login_challenges::handlers::create,
|
||||
crate::http_server::endpoints::login_challenges::handlers::review,
|
||||
crate::http_server::endpoints::login_challenges::handlers::decide,
|
||||
crate::http_server::endpoints::login_challenges::handlers::poll,
|
||||
crate::http_server::endpoints::users::logout_handler::logout_handler,
|
||||
crate::http_server::endpoints::users::session_info_handler::session_info_handler,
|
||||
crate::http_server::endpoints::users::session_token_info_handler::session_token_info_handler,
|
||||
@@ -917,9 +924,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
CreateCommentSuccessResponse,
|
||||
CreateFeaturedItemRequest,
|
||||
CreateFeaturedItemSuccessResponse,
|
||||
CreateLoginChallengeRequest,
|
||||
CreateLoginChallengeResponse,
|
||||
CreateUserBookmarkError,
|
||||
CreateUserBookmarkRequest,
|
||||
CreateUserBookmarkSuccessResponse,
|
||||
DecideLoginChallengeRequest,
|
||||
DeleteCommentError,
|
||||
DeleteCommentPathInfo,
|
||||
DeleteCommentRequest,
|
||||
@@ -1174,6 +1184,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
ListMediaFilesForUserPathInfo,
|
||||
ListMediaFilesForUserQueryParams,
|
||||
ListMediaFilesForUserSuccessResponse,
|
||||
ListMediaFilesForUserV2PathInfo,
|
||||
ListMediaFilesForUserV2QueryParams,
|
||||
ListMediaFilesForUserV2SuccessResponse,
|
||||
UserMediaFileV2Pagination,
|
||||
ListMediaFilesQueryParams,
|
||||
ListMediaFilesSuccessResponse,
|
||||
ListPinnedMediaFilesSuccessResponse,
|
||||
@@ -1200,6 +1214,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
LogBrowserSessionError,
|
||||
LogBrowserSessionRequest,
|
||||
LogBrowserSessionSuccessResponse,
|
||||
LoginChallengeFailure,
|
||||
LoginChallengeResponse,
|
||||
LoginChallengeState,
|
||||
LoginErrorResponse,
|
||||
LoginErrorType,
|
||||
LoginRequest,
|
||||
@@ -1226,6 +1243,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
NewProjectMultipartForm,
|
||||
PinnedMediaFile,
|
||||
PinnedModelWeightForList,
|
||||
PollLoginChallengeRequest,
|
||||
ProjectMediaFileInfo,
|
||||
SessionMediaFileInfo,
|
||||
PromptInfo,
|
||||
@@ -1237,6 +1255,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
RenameMediaFileRequest,
|
||||
RequestDetailsResponse,
|
||||
ResultDetailsResponse,
|
||||
ReviewLoginChallengeRequest,
|
||||
ReviewLoginChallengeResponse,
|
||||
StaffAuditLogResponse,
|
||||
SearchFeaturedMediaFileListItem,
|
||||
SearchFeaturedMediaFilesQueryParams,
|
||||
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
//! End-to-end HTTP endpoint tests: real TCP, production handlers/signers, real MySQL fixtures.
|
||||
//! The API binds only 127.0.0.1:0; MySQL uses the isolated fixed-local-socket harness.
|
||||
//! No mocks of queries, login handlers, session responses, or cookies.
|
||||
use std::net::TcpListener;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_web::dev::ServerHandle;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeResponse, LoginChallengeFailure, LoginChallengeResponse, LoginChallengeState, ReviewLoginChallengeResponse};
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use mysql_testing::fixtures::login_challenges as fixtures;
|
||||
use mysql_testing::fixtures::users::{create_test_user, TestUser};
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use reqwest::cookie::Jar;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, SET_COOKIE};
|
||||
use reqwest::{Client, Response, StatusCode, Url};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::http_server::endpoints::users::session_info_handler::session_info_handler;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
|
||||
use super::{handlers, security};
|
||||
|
||||
const BROWSER_ORIGIN: &str = "https://getartcraft.com";
|
||||
const TEST_SIGNING_KEY: &str = "local_endpoint_integration_test_key";
|
||||
|
||||
struct EndpointHarness {
|
||||
db: IsolatedTestDatabase,
|
||||
user: TestUser,
|
||||
signer: HttpUserSessionManager,
|
||||
server: ServerHandle,
|
||||
base_url: Url,
|
||||
desktop: Client,
|
||||
browser: Client,
|
||||
desktop_ip: String,
|
||||
browser_ip: String,
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn approved_passwordless_login_redeems_once_and_authenticates_real_session_endpoint() {
|
||||
let h = EndpointHarness::start().await;
|
||||
fixtures::assert_challenge_status_is_explicit(&h.db.pool).await;
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let browser_session = h.session(&h.browser).await;
|
||||
assert_eq!(browser_session["user"]["user_token"], h.user.user_token.as_str());
|
||||
|
||||
let created = h.create().await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
let initial = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(initial.status, UserLoginChallengeStatus::Pending);
|
||||
assert_eq!(initial.lifetime_seconds, 1200);
|
||||
assert_eq!(initial.creation_ip, h.desktop_ip);
|
||||
assert!(initial.deciding_user.is_none());
|
||||
assert!(initial.decided_at.is_none() && initial.redeemed_at.is_none() && initial.failed_at.is_none());
|
||||
assert!(initial.session_id.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
|
||||
let pending = h.poll(&created).await;
|
||||
assert_private_response(&pending);
|
||||
assert_no_session_cookie(&pending);
|
||||
let pending: LoginChallengeResponse = read_ok(pending).await;
|
||||
assert_eq!(pending.status, LoginChallengeState::Pending);
|
||||
assert!(pending.maybe_signed_session.is_none());
|
||||
assert_eq!(h.review(&h.desktop, &created).await.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let review: ReviewLoginChallengeResponse = read_ok(h.review(&h.browser, &created).await).await;
|
||||
assert_eq!(review.status, LoginChallengeState::Pending);
|
||||
assert_eq!(review.requesting_ip, h.desktop_ip);
|
||||
assert_eq!(review.confirmation_code, created.confirmation_code);
|
||||
assert_eq!(review.username, h.user.username);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
|
||||
let approved = h.decide(&h.browser, &created, true).await;
|
||||
assert_private_response(&approved);
|
||||
assert_no_session_cookie(&approved);
|
||||
let approved: LoginChallengeResponse = read_ok(approved).await;
|
||||
assert_eq!(approved.status, LoginChallengeState::Approved);
|
||||
assert!(approved.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0, "approval must not mint a session");
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let approved_audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(approved_audit.deciding_user.as_deref(), Some(h.user.user_token.as_str()));
|
||||
assert_eq!(approved_audit.decision_ip.as_deref(), Some(h.browser_ip.as_str()));
|
||||
assert!(approved_audit.decided_at.is_some());
|
||||
assert!(approved_audit.redeemed_at.is_none() && approved_audit.session_id.is_none());
|
||||
|
||||
// Overlapping real HTTP requests serialize through the production row lock.
|
||||
let (first, retry) = tokio::join!(h.poll(&created), h.poll(&created));
|
||||
assert_private_response(&first);
|
||||
let cookie = session_cookie(&first).expect("redemption Set-Cookie");
|
||||
assert_eq!(cookie.path(), Some("/"));
|
||||
let first: LoginChallengeResponse = read_ok(first).await;
|
||||
let retry: LoginChallengeResponse = read_ok(retry).await;
|
||||
assert_eq!(first.status, LoginChallengeState::Redeemed);
|
||||
assert_eq!(first.maybe_signed_session, retry.maybe_signed_session);
|
||||
assert_eq!(first.maybe_signed_session.as_deref(), Some(cookie.value()));
|
||||
assert_ne!(cookie.value(), h.signer.create_cookie(&h.user.session_token, &h.user.user_token).unwrap().value());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
|
||||
// No injected cookie or canned response: reqwest sends the Set-Cookie it received.
|
||||
let session = h.session(&h.desktop).await;
|
||||
assert_eq!(session["success"], true);
|
||||
assert_eq!(session["logged_in"], true);
|
||||
assert_eq!(session["user"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(session["user"]["core_info"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(session["user"]["username"], h.user.username);
|
||||
assert_eq!(session["user"]["onboarding"]["password_not_set"], true);
|
||||
assert_eq!(session["user"]["onboarding"]["email_not_confirmed"], false);
|
||||
assert_eq!(session["user"]["maybe_feature_flags"], json!(["studio", "use_qt"]));
|
||||
assert_eq!(session["user"]["can_access_studio"], true);
|
||||
assert_eq!(session["user"]["can_ban_users"], false);
|
||||
assert!(session.get("signed_session").is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.status, UserLoginChallengeStatus::Redeemed);
|
||||
assert_eq!(audit.redemption_ip.as_deref(), Some(h.desktop_ip.as_str()));
|
||||
assert!(audit.session_id.is_some());
|
||||
assert!(audit.redeemed_at.unwrap() >= audit.decided_at.unwrap());
|
||||
assert!(audit.failed_at.is_none());
|
||||
|
||||
// Losing the first response is recoverable from a fresh client with no cookies.
|
||||
let fresh = h.client("192.0.2.250", None);
|
||||
let replay: LoginChallengeResponse = read_ok(h.post(&fresh, "poll", json!({"device_token":created.device_token})).await).await;
|
||||
assert_eq!(replay.maybe_signed_session, first.maybe_signed_session);
|
||||
assert_eq!(h.session(&fresh).await["user"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
let expired = h.poll(&created).await;
|
||||
assert_no_session_cookie(&expired);
|
||||
let expired: LoginChallengeResponse = read_ok(expired).await;
|
||||
assert_eq!(expired.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(expired.maybe_signed_session.is_none());
|
||||
assert_eq!(h.session(&h.desktop).await["logged_in"], true, "session outlives the handoff");
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn rejected_and_expired_requests_never_authenticate_the_desktop() {
|
||||
let h = EndpointHarness::start().await;
|
||||
for mode in ["declined", "expired_pending", "expired_approved"] {
|
||||
let created = h.create().await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
let review: ReviewLoginChallengeResponse = read_ok(h.review(&h.browser, &created).await).await;
|
||||
assert_eq!(review.status, LoginChallengeState::Pending);
|
||||
if mode == "declined" {
|
||||
let declined = h.decide(&h.browser, &created, false).await;
|
||||
assert_no_session_cookie(&declined);
|
||||
let declined: LoginChallengeResponse = read_ok(declined).await;
|
||||
assert_eq!(declined.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
let replay: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
assert_eq!(replay.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
} else {
|
||||
if mode == "expired_approved" {
|
||||
let approved: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
assert_eq!(approved.status, LoginChallengeState::Approved);
|
||||
}
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
}
|
||||
let response = h.poll(&created).await;
|
||||
assert_private_response(&response);
|
||||
assert_no_session_cookie(&response);
|
||||
let result: LoginChallengeResponse = read_ok(response).await;
|
||||
assert_eq!(result.status, LoginChallengeState::Failed);
|
||||
assert_eq!(result.maybe_failure_type, Some(if mode == "declined" { LoginChallengeFailure::UserDeclined } else { LoginChallengeFailure::Expired }));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert!(audit.session_id.is_none() && audit.redeemed_at.is_none());
|
||||
assert!(audit.failed_at.is_some());
|
||||
assert_eq!(audit.creation_ip, h.desktop_ip);
|
||||
assert_eq!(audit.failure_ip.as_deref(), if mode == "declined" { Some(h.browser_ip.as_str()) } else { None });
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
}
|
||||
assert_eq!(h.session(&h.browser).await["logged_in"], true);
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn revoked_expired_and_banned_sessions_fail_real_downstream_requests_and_cannot_be_reminted() {
|
||||
let h = EndpointHarness::start().await;
|
||||
for (index, mode) in ["revoked", "expired", "banned"].iter().copied().enumerate() {
|
||||
let created = h.create().await;
|
||||
let _: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
let result: LoginChallengeResponse = read_ok(h.poll(&created).await).await;
|
||||
assert_eq!(h.session(&h.desktop).await["logged_in"], true);
|
||||
let signed = result.maybe_signed_session.unwrap();
|
||||
let request = actix_web::test::TestRequest::default().insert_header(("session", signed)).to_http_request();
|
||||
let token = h.signer.decode_session_payload_from_request(&request).unwrap().unwrap().session_token;
|
||||
match mode {
|
||||
"revoked" => fixtures::revoke_session(&h.db.pool, &token).await,
|
||||
"expired" => fixtures::expire_session(&h.db.pool, &token).await,
|
||||
_ => fixtures::ban_user(&h.db.pool, &h.user.user_token).await,
|
||||
}
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let retry = h.poll(&created).await;
|
||||
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_no_session_cookie(&retry);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, (index + 1) as i64);
|
||||
}
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn a_second_account_cannot_replace_the_user_who_approved() {
|
||||
let h = EndpointHarness::start().await;
|
||||
let other = create_test_user(&h.db.pool).await.unwrap();
|
||||
let other_browser = h.client("198.51.100.250", Some(&other));
|
||||
let created = h.create().await;
|
||||
let _: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
for approve in [true, false] {
|
||||
let replay: LoginChallengeResponse = read_ok(h.decide(&other_browser, &created, approve).await).await;
|
||||
assert_eq!(replay.status, LoginChallengeState::Approved);
|
||||
assert!(replay.maybe_signed_session.is_none());
|
||||
}
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
let _: LoginChallengeResponse = read_ok(h.poll(&created).await).await;
|
||||
assert_eq!(h.session(&h.desktop).await["user"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(h.session(&other_browser).await["user"]["user_token"], other.user_token.as_str());
|
||||
let audit = fixtures::audit(&h.db.pool, &security::secret_hash(&created.device_token).unwrap()).await;
|
||||
assert_eq!(audit.deciding_user.as_deref(), Some(h.user.user_token.as_str()));
|
||||
assert_eq!(audit.decision_ip.as_deref(), Some(h.browser_ip.as_str()));
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
impl EndpointHarness {
|
||||
async fn start() -> Self {
|
||||
static NEXT_IP: AtomicU16 = AtomicU16::new(128);
|
||||
let suffix = NEXT_IP.fetch_add(1, Ordering::Relaxed);
|
||||
let desktop_ip = format!("192.0.2.{suffix}");
|
||||
let browser_ip = format!("198.51.100.{suffix}");
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let user = fixtures::create_passwordless_user(&db.pool, &[UserFeatureFlag::Studio, UserFeatureFlag::CanUseQuicktime]).await;
|
||||
let signer = HttpUserSessionManager::new("localhost", TEST_SIGNING_KEY).unwrap();
|
||||
let checker = SessionChecker::new(&signer);
|
||||
let avt = AvtCookieManager::new("localhost", TEST_SIGNING_KEY).unwrap();
|
||||
let pool = db.pool.clone();
|
||||
let app_signer = signer.clone();
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
assert!(address.ip().is_loopback());
|
||||
let base_url = Url::parse(&format!("http://{address}")).unwrap();
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(pool.clone()))
|
||||
.app_data(web::Data::new(app_signer.clone()))
|
||||
.app_data(web::Data::new(checker.clone()))
|
||||
.app_data(web::Data::new(avt.clone()))
|
||||
.configure(handlers::configure)
|
||||
.route("/v1/session", web::get().to(session_info_handler))
|
||||
}).workers(1).disable_signals().listen(listener).unwrap().run();
|
||||
let handle = server.handle();
|
||||
actix_web::rt::spawn(server);
|
||||
let desktop = client(&base_url, &desktop_ip, None);
|
||||
let cookie = signer.create_cookie(&user.session_token, &user.user_token).unwrap().to_string();
|
||||
let browser = client(&base_url, &browser_ip, Some(&cookie));
|
||||
Self { db, user, signer, server: handle, base_url, desktop, browser, desktop_ip, browser_ip }
|
||||
}
|
||||
|
||||
fn client(&self, ip: &str, user: Option<&TestUser>) -> Client {
|
||||
let cookie = user.map(|u| self.signer.create_cookie(&u.session_token, &u.user_token).unwrap().to_string());
|
||||
client(&self.base_url, ip, cookie.as_deref())
|
||||
}
|
||||
|
||||
async fn create(&self) -> CreateLoginChallengeResponse {
|
||||
let response = self.post(&self.desktop, "create", json!({})).await;
|
||||
assert_private_response(&response);
|
||||
assert_no_session_cookie(&response);
|
||||
let created: CreateLoginChallengeResponse = read_ok(response).await;
|
||||
assert!(created.success);
|
||||
assert!(!created.verification_url.contains(&created.device_token));
|
||||
created
|
||||
}
|
||||
|
||||
async fn review(&self, client: &Client, created: &CreateLoginChallengeResponse) -> Response {
|
||||
self.post(client, "review", json!({"approval_token": approval_token(created)})).await
|
||||
}
|
||||
|
||||
async fn decide(&self, client: &Client, created: &CreateLoginChallengeResponse, approve: bool) -> Response {
|
||||
self.post(client, "decide", json!({"approval_token": approval_token(created), "approve": approve})).await
|
||||
}
|
||||
|
||||
async fn poll(&self, created: &CreateLoginChallengeResponse) -> Response {
|
||||
self.post(&self.desktop, "poll", json!({"device_token": created.device_token})).await
|
||||
}
|
||||
|
||||
async fn post(&self, client: &Client, action: &str, body: Value) -> Response {
|
||||
client.post(self.base_url.join(&format!("/v1/login_challenges/{action}")).unwrap()).json(&body).send().await.unwrap()
|
||||
}
|
||||
|
||||
async fn session(&self, client: &Client) -> Value {
|
||||
read_ok(client.get(self.base_url.join("/v1/session").unwrap()).send().await.unwrap()).await
|
||||
}
|
||||
|
||||
async fn finish(self) {
|
||||
self.server.stop(true).await;
|
||||
self.db.destroy().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn client(base_url: &Url, ip: &str, cookie: Option<&str>) -> Client {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let jar = Arc::new(Jar::default());
|
||||
if let Some(cookie) = cookie { jar.add_cookie_str(cookie, base_url); }
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Origin", HeaderValue::from_static(BROWSER_ORIGIN));
|
||||
// Model the production gateway forwarding different browser and desktop IPs.
|
||||
headers.insert("X-Forwarded-For", HeaderValue::from_str(ip).unwrap());
|
||||
Client::builder().no_proxy().redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(10)).default_headers(headers).cookie_provider(jar).build().unwrap()
|
||||
}
|
||||
|
||||
async fn read_ok<T: DeserializeOwned>(response: Response) -> T {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response.json().await.unwrap()
|
||||
}
|
||||
|
||||
fn approval_token(created: &CreateLoginChallengeResponse) -> &str {
|
||||
created.verification_url.split_once("#approval_token=").unwrap().1
|
||||
}
|
||||
|
||||
fn session_cookie(response: &Response) -> Option<actix_web::cookie::Cookie<'static>> {
|
||||
response.headers().get_all(SET_COOKIE).iter()
|
||||
.filter_map(|value| actix_web::cookie::Cookie::parse(value.to_str().unwrap()).ok())
|
||||
.find(|cookie| cookie.name() == "session").map(|cookie| cookie.into_owned())
|
||||
}
|
||||
|
||||
fn assert_no_session_cookie(response: &Response) {
|
||||
assert!(session_cookie(response).is_none());
|
||||
}
|
||||
|
||||
fn assert_private_response(response: &Response) {
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
assert_eq!(response.headers()["referrer-policy"], "no-referrer");
|
||||
}
|
||||
|
||||
fn assert_anonymous(session: Value) {
|
||||
assert_eq!(session["success"], true);
|
||||
assert_eq!(session["logged_in"], false);
|
||||
assert!(session["user"].is_null());
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_web::middleware::DefaultHeaders;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use artcraft_api_defs::users::login_challenges::*;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use mysql_queries::queries::user_login_challenges::create_challenge::{create_challenge, CreateChallengeArgs};
|
||||
use mysql_queries::queries::user_login_challenges::decide_challenge::{decide_challenge, DecideChallengeArgs};
|
||||
use mysql_queries::queries::user_login_challenges::expire_challenge::{expire_challenge, ExpireChallengeArgs};
|
||||
use mysql_queries::queries::user_login_challenges::is_challenge_expired::{is_challenge_expired, IsChallengeExpiredArgs};
|
||||
use mysql_queries::queries::user_login_challenges::lock_by_approval::{lock_by_approval, LockByApprovalArgs};
|
||||
use mysql_queries::queries::user_login_challenges::lock_by_device::{lock_by_device, LockByDeviceArgs};
|
||||
use mysql_queries::queries::user_login_challenges::login_challenge::LoginChallenge;
|
||||
use mysql_queries::queries::user_login_challenges::mark_redeemed::{mark_redeemed, MarkRedeemedArgs};
|
||||
use mysql_queries::queries::users::user_sessions::bridge_session::BridgeSession;
|
||||
use mysql_queries::queries::users::user_sessions::create_bridge_session::{create_bridge_session, CreateBridgeSessionArgs};
|
||||
use mysql_queries::queries::users::user_sessions::find_approving_session::{find_approving_session, FindApprovingSessionArgs};
|
||||
use mysql_queries::queries::users::user_sessions::find_redeemed_session::{find_redeemed_session, FindRedeemedSessionArgs};
|
||||
use sqlx::{Executor, MySql, MySqlPool, Transaction};
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use super::security::{new_confirmation_code, new_secret, rate_limit, request_ip, require_approval_origin, secret_hash};
|
||||
|
||||
const VERIFICATION_PAGE: &str = "https://app.getartcraft.com/login/desktop";
|
||||
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/v1/login_challenges").wrap(DefaultHeaders::new().add(("Cache-Control", "no-store")).add(("Referrer-Policy", "no-referrer"))).app_data(web::JsonConfig::default().limit(1024)).route("/create", web::post().to(create)).route("/review", web::post().to(review)).route("/decide", web::post().to(decide)).route("/poll", web::post().to(poll)));
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/create", tag = "Users", request_body = CreateLoginChallengeRequest,
|
||||
responses((status = 200, body = CreateLoginChallengeResponse), (status = 429, body = CommonWebError)))]
|
||||
pub async fn create(request: HttpRequest, _body: web::Json<CreateLoginChallengeRequest>, pool: web::Data<MySqlPool>) -> Result<web::Json<CreateLoginChallengeResponse>, CommonWebError> {
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, true)?;
|
||||
let approval_token = new_secret()?;
|
||||
let device_token = new_secret()?;
|
||||
let approval_hash = secret_hash(&approval_token)?;
|
||||
let device_hash = secret_hash(&device_token)?;
|
||||
let code = new_confirmation_code()?;
|
||||
let token = UserLoginChallengeToken::generate();
|
||||
let mut conn = pool.acquire().await?;
|
||||
create_challenge(CreateChallengeArgs {
|
||||
token: &token,
|
||||
approval_hash: &approval_hash,
|
||||
device_hash: &device_hash,
|
||||
confirmation_code: &code,
|
||||
status: UserLoginChallengeStatus::Pending,
|
||||
ip_address: &ip,
|
||||
mysql_executor: &mut *conn,
|
||||
}).await?;
|
||||
let challenge = lock_by_device(LockByDeviceArgs {
|
||||
device_hash: &device_hash,
|
||||
mysql_executor: &mut *conn,
|
||||
}).await?.ok_or(CommonWebError::NotFound)?;
|
||||
Ok(web::Json(CreateLoginChallengeResponse {
|
||||
success: true,
|
||||
device_token,
|
||||
// A fragment avoids sending the approval credential to the website's HTTP logs.
|
||||
verification_url: format!("{}#approval_token={approval_token}", verification_page(&request)),
|
||||
confirmation_code: code,
|
||||
expires_at: challenge.expires_at,
|
||||
poll_interval_seconds: 5,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/review", tag = "Users", request_body = ReviewLoginChallengeRequest,
|
||||
responses((status = 200, body = ReviewLoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
|
||||
pub async fn review(request: HttpRequest, body: web::Json<ReviewLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<ReviewLoginChallengeResponse>, CommonWebError> {
|
||||
require_approval_origin(&request)?;
|
||||
rate_limit(&request_ip(&request), false)?;
|
||||
let hash = secret_hash(&body.approval_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let approver = approving_session(&request, &signer, &mut *tx).await?;
|
||||
let mut challenge = lock_by_approval(LockByApprovalArgs {
|
||||
approval_hash: &hash,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
let result = outcome(&challenge, expired);
|
||||
tx.commit().await?;
|
||||
Ok(web::Json(ReviewLoginChallengeResponse { success: true, status: result.status, maybe_failure_type: result.maybe_failure_type, confirmation_code: challenge.confirmation_code, requesting_ip: challenge.ip_address_creation, expires_at: challenge.expires_at, username: approver.username }))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/decide", tag = "Users", request_body = DecideLoginChallengeRequest,
|
||||
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
|
||||
pub async fn decide(request: HttpRequest, body: web::Json<DecideLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<LoginChallengeResponse>, CommonWebError> {
|
||||
require_approval_origin(&request)?;
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, false)?;
|
||||
let hash = secret_hash(&body.approval_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let approver = approving_session(&request, &signer, &mut *tx).await?;
|
||||
let mut challenge = lock_by_approval(LockByApprovalArgs {
|
||||
approval_hash: &hash,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let mut expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
if !expired && challenge.status == UserLoginChallengeStatus::Pending {
|
||||
if decide_challenge(DecideChallengeArgs {
|
||||
challenge_id: challenge.id,
|
||||
user_token: &approver.user_token,
|
||||
ip_address: &ip,
|
||||
approve: body.approve,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await? {
|
||||
challenge.status = if body.approve { UserLoginChallengeStatus::Approved } else { UserLoginChallengeStatus::Failed };
|
||||
challenge.maybe_failure_type = if body.approve { None } else { Some(UserLoginChallengeFailureType::UserDeclined) };
|
||||
} else {
|
||||
expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
}
|
||||
}
|
||||
// No session INSERT occurs here, including on approval. Terminal decisions are immutable.
|
||||
let result = outcome(&challenge, expired);
|
||||
tx.commit().await?;
|
||||
Ok(web::Json(result))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/poll", tag = "Users", request_body = PollLoginChallengeRequest,
|
||||
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 429, body = CommonWebError)))]
|
||||
pub async fn poll(request: HttpRequest, body: web::Json<PollLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<HttpResponse, CommonWebError> {
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, false)?;
|
||||
let hash = secret_hash(&body.device_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut challenge = lock_by_device(LockByDeviceArgs {
|
||||
device_hash: &hash,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
let mut result = outcome(&challenge, expired);
|
||||
if expired || challenge.status == UserLoginChallengeStatus::Pending || challenge.status == UserLoginChallengeStatus::Failed {
|
||||
tx.commit().await?;
|
||||
return Ok(HttpResponse::Ok().json(result));
|
||||
}
|
||||
let user = challenge.maybe_deciding_user_token.as_deref().ok_or(CommonWebError::NotAuthorized)?;
|
||||
let session = if challenge.status == UserLoginChallengeStatus::Approved {
|
||||
let session_id = create_bridge_session(CreateBridgeSessionArgs {
|
||||
user_token: user,
|
||||
ip_address: &ip,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let session = find_redeemed_session(FindRedeemedSessionArgs {
|
||||
session_id,
|
||||
user_token: user,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
if !mark_redeemed(MarkRedeemedArgs {
|
||||
challenge_id: challenge.id,
|
||||
session_id: session.id,
|
||||
ip_address: &ip,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await? {
|
||||
// Includes expiry during redemption. Roll back the INSERT as well.
|
||||
tx.rollback().await?;
|
||||
return Ok(HttpResponse::Ok().json(outcome(&challenge, true)));
|
||||
}
|
||||
session
|
||||
} else if challenge.status == UserLoginChallengeStatus::Redeemed {
|
||||
let id = challenge.maybe_redeemed_user_session_id.ok_or(CommonWebError::NotAuthorized)?;
|
||||
find_redeemed_session(FindRedeemedSessionArgs {
|
||||
session_id: id,
|
||||
user_token: user,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?
|
||||
} else {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
};
|
||||
let cookie = signer.create_cookie(&UserSessionToken::new(session.token), &UserToken::new(session.user_token))?;
|
||||
result.status = LoginChallengeState::Redeemed;
|
||||
result.maybe_signed_session = Some(cookie.value().to_owned());
|
||||
// The new session, challenge transition, and signing must ALL succeed first.
|
||||
tx.commit().await?;
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(result))
|
||||
}
|
||||
|
||||
/// Both queries must run in the transaction that already holds the challenge lock.
|
||||
async fn expire_locked_challenge(challenge: &mut LoginChallenge, tx: &mut Transaction<'_, MySql>) -> Result<bool, sqlx::Error> {
|
||||
let expired = is_challenge_expired(IsChallengeExpiredArgs {
|
||||
challenge_id: challenge.id,
|
||||
mysql_executor: &mut **tx,
|
||||
}).await?;
|
||||
if expired && (challenge.status == UserLoginChallengeStatus::Pending || challenge.status == UserLoginChallengeStatus::Approved) {
|
||||
expire_challenge(ExpireChallengeArgs {
|
||||
challenge_id: challenge.id,
|
||||
mysql_executor: &mut **tx,
|
||||
}).await?;
|
||||
challenge.status = UserLoginChallengeStatus::Failed;
|
||||
challenge.maybe_failure_type = Some(UserLoginChallengeFailureType::Expired);
|
||||
}
|
||||
Ok(expired)
|
||||
}
|
||||
|
||||
async fn approving_session<'c, T>(request: &HttpRequest, signer: &HttpUserSessionManager, mysql_executor: T) -> Result<BridgeSession, CommonWebError>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let payload = signer.decode_session_payload_from_request(request).map_err(|_| CommonWebError::NotAuthorized)?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
find_approving_session(FindApprovingSessionArgs {
|
||||
session_token: &payload.session_token,
|
||||
mysql_executor,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)
|
||||
}
|
||||
|
||||
fn verification_page(request: &HttpRequest) -> &'static str {
|
||||
let host = request.connection_info().host().to_owned();
|
||||
if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") {
|
||||
"http://localhost:4201/login/desktop"
|
||||
} else {
|
||||
VERIFICATION_PAGE
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome(challenge: &LoginChallenge, expired: bool) -> LoginChallengeResponse {
|
||||
// Keep an explicit decline even when its deadline has since elapsed.
|
||||
let declined = challenge.maybe_failure_type == Some(UserLoginChallengeFailureType::UserDeclined);
|
||||
let (status, failure) = if expired && !declined {
|
||||
(LoginChallengeState::Failed, Some(LoginChallengeFailure::Expired))
|
||||
} else {
|
||||
let status = match challenge.status {
|
||||
UserLoginChallengeStatus::Pending => LoginChallengeState::Pending,
|
||||
UserLoginChallengeStatus::Approved => LoginChallengeState::Approved,
|
||||
UserLoginChallengeStatus::Redeemed => LoginChallengeState::Redeemed,
|
||||
UserLoginChallengeStatus::Failed => LoginChallengeState::Failed,
|
||||
};
|
||||
let failure = match challenge.maybe_failure_type {
|
||||
Some(UserLoginChallengeFailureType::UserDeclined) => Some(LoginChallengeFailure::UserDeclined),
|
||||
Some(UserLoginChallengeFailureType::Expired) => Some(LoginChallengeFailure::Expired),
|
||||
None => None,
|
||||
};
|
||||
(status, failure)
|
||||
};
|
||||
LoginChallengeResponse { success: true, status, maybe_failure_type: failure, maybe_signed_session: None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod website_origin_tests {
|
||||
use super::verification_page;
|
||||
use actix_web::test::TestRequest;
|
||||
|
||||
#[test]
|
||||
fn local_challenges_point_at_the_local_approval_site() {
|
||||
let request = TestRequest::default().insert_header(("Host", "localhost:12345")).to_http_request();
|
||||
assert_eq!(verification_page(&request), "http://localhost:4201/login/desktop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_challenges_point_at_the_production_approval_site() {
|
||||
let request = TestRequest::default().insert_header(("Host", "api.storyteller.ai")).to_http_request();
|
||||
assert_eq!(verification_page(&request), "https://app.getartcraft.com/login/desktop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod handlers;
|
||||
mod security;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod endpoint_integration_tests;
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use rand::{
|
||||
rngs::{OsRng, StdRng},
|
||||
Rng, SeedableRng, TryRngCore,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
const CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
|
||||
const APPROVAL_ORIGINS: &[&str] = &["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"];
|
||||
const WINDOW: Duration = Duration::from_secs(60);
|
||||
const MAX_BUCKETS: usize = 10_000;
|
||||
|
||||
/// Follow the existing gateway's forwarded-IP policy, but normalize stored IPs
|
||||
/// and use the socket address for direct IPv6 requests (the legacy helper splits
|
||||
/// on ':'). Gateway deployments must strip caller-supplied forwarding headers.
|
||||
pub fn request_ip(request: &HttpRequest) -> String {
|
||||
get_request_ip(request).parse::<IpAddr>().ok().or_else(|| request.peer_addr().map(|address| address.ip())).map(|ip| ip.to_string()).unwrap_or_else(|| "0.0.0.0".into())
|
||||
}
|
||||
|
||||
pub fn new_secret() -> Result<String, CommonWebError> {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.try_fill_bytes(&mut bytes).map_err(CommonWebError::from_error)?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn secret_hash(secret: &str) -> Result<[u8; 32], CommonWebError> {
|
||||
if secret.len() != 43 {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD.decode(secret).map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
if bytes.len() != 32 || URL_SAFE_NO_PAD.encode(&bytes) != secret {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
Ok(Sha256::digest(secret.as_bytes()).into())
|
||||
}
|
||||
|
||||
pub fn new_confirmation_code() -> Result<String, CommonWebError> {
|
||||
let mut rng = StdRng::try_from_os_rng().map_err(CommonWebError::from_error)?;
|
||||
Ok((0..8).map(|_| CODE_ALPHABET[rng.random_range(0..CODE_ALPHABET.len())] as char).collect())
|
||||
}
|
||||
|
||||
/// Approval is browser-only and requires an exact trusted Origin. JSON POSTs
|
||||
/// plus Origin validation prevent ambient cookies from granting cross-site consent.
|
||||
pub fn require_approval_origin(request: &HttpRequest) -> Result<(), CommonWebError> {
|
||||
let origin = request.headers().get("Origin").and_then(|v| v.to_str().ok()).ok_or(CommonWebError::Forbidden)?;
|
||||
if APPROVAL_ORIGINS.contains(&origin) {
|
||||
return Ok(());
|
||||
}
|
||||
// Local web development is permitted only when the API is also local.
|
||||
let host = request.connection_info().host().to_owned();
|
||||
let local_api = host.starts_with("localhost:") || host.starts_with("127.0.0.1:");
|
||||
if local_api && ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CommonWebError::Forbidden)
|
||||
}
|
||||
|
||||
/// Per-process abuse cap, shared by all Actix workers. Entries expire and memory
|
||||
/// is bounded. Deployments can impose stricter aggregate limits at their gateway.
|
||||
pub fn rate_limit(ip: &str, create: bool) -> Result<(), CommonWebError> {
|
||||
static BUCKETS: OnceLock<Mutex<HashMap<(String, bool), (Instant, u32)>>> = OnceLock::new();
|
||||
let mut buckets = BUCKETS.get_or_init(|| Mutex::new(HashMap::new())).lock().map_err(|_| CommonWebError::TooManyRequests)?;
|
||||
let now = Instant::now();
|
||||
buckets.retain(|_, (started, _)| now.duration_since(*started) < WINDOW);
|
||||
let key = (ip.to_owned(), create);
|
||||
if buckets.len() >= MAX_BUCKETS && !buckets.contains_key(&key) {
|
||||
return Err(CommonWebError::TooManyRequests);
|
||||
}
|
||||
let (_, count) = buckets.entry(key).or_insert((now, 0));
|
||||
let limit = if create { 10 } else { 120 };
|
||||
if *count >= limit {
|
||||
return Err(CommonWebError::TooManyRequests);
|
||||
}
|
||||
*count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::test::TestRequest;
|
||||
use super::require_approval_origin;
|
||||
|
||||
#[test]
|
||||
fn both_local_sites_require_a_local_api() {
|
||||
for origin in ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"] {
|
||||
for host in ["localhost:12345", "127.0.0.1:12345", "api.storyteller.ai"] {
|
||||
let request = TestRequest::post().insert_header(("Host", host)).insert_header(("Origin", origin)).to_http_request();
|
||||
assert_eq!(require_approval_origin(&request).is_ok(), host != "api.storyteller.ai");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_origins_require_exact_matches() {
|
||||
for origin in ["https://getartcraft.com.evil.example", "https://app.getartcraft.com.evil.example", "http://getartcraft.com", "http://localhost:4202", "null"] {
|
||||
let request = TestRequest::post().insert_header(("Host", "localhost:12345")).insert_header(("Origin", origin)).to_http_request();
|
||||
assert!(require_approval_origin(&request).is_err());
|
||||
}
|
||||
assert!(require_approval_origin(&TestRequest::post().to_http_request()).is_err());
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
//! Real HTTP handlers + signed cookies + disposable local MySQL databases.
|
||||
//! No application URLs, production DB configuration, Redis, or external providers.
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_http::Request;
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{Service, ServiceResponse};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App, HttpRequest};
|
||||
use artcraft_api_defs::users::login_challenges::*;
|
||||
use mysql_queries::queries::user_login_challenges::expire_abandoned_challenges::{expire_abandoned_challenges, ExpireAbandonedChallengesArgs};
|
||||
use mysql_queries::queries::user_login_challenges::lock_by_device::{lock_by_device, LockByDeviceArgs};
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use mysql_testing::fixtures::login_challenges as fixtures;
|
||||
use mysql_testing::fixtures::mcp_sessions::create_test_mcp_session;
|
||||
use mysql_testing::fixtures::users::{create_test_user, TestUser};
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
|
||||
use super::{handlers, security};
|
||||
|
||||
const ORIGIN: &str = "https://app.getartcraft.com";
|
||||
const BROWSER_IP: &str = "198.51.100.20";
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn both_browser_sites_can_review_approve_and_decline() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
let mut minted = 0;
|
||||
for origin in ["https://getartcraft.com", "https://www.getartcraft.com", "https://app.getartcraft.com"] {
|
||||
for approve in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/review").insert_header(("Origin", origin)).insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval})).to_request();
|
||||
let review: ReviewLoginChallengeResponse = test::call_and_read_body_json(&app, request).await;
|
||||
assert_eq!(review.status, LoginChallengeState::Pending);
|
||||
assert_eq!(review.username, h.user.username);
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", origin)).insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval, "approve": approve})).to_request();
|
||||
let decision: LoginChallengeResponse = test::call_and_read_body_json(&app, request).await;
|
||||
assert!(decision.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, minted);
|
||||
let polled = post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await;
|
||||
if approve {
|
||||
let cookie = polled.response().cookies().find(|c| c.name() == "session").unwrap().into_owned();
|
||||
let downstream = test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie).to_request()).await;
|
||||
assert_eq!(downstream.status(), StatusCode::OK);
|
||||
let body: Value = test::read_body_json(downstream).await;
|
||||
assert_eq!(body["user_token"], h.user.user_token.as_str());
|
||||
minted += 1;
|
||||
} else {
|
||||
assert!(!polled.headers().contains_key("set-cookie"));
|
||||
let result: LoginChallengeResponse = test::read_body_json(polled).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
}
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, minted);
|
||||
}
|
||||
}
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn approval_mints_once_and_cookie_authenticates_downstream() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
fixtures::set_passwordless(&h.db.pool, &h.user.user_token).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert!(!created.verification_url.contains(&created.device_token));
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.lifetime_seconds, 1200);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
|
||||
let pending = post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await;
|
||||
assert!(!pending.headers().contains_key("set-cookie"));
|
||||
assert_eq!(pending.headers().get("cache-control").unwrap(), "no-store");
|
||||
let pending: LoginChallengeResponse = test::read_body_json(pending).await;
|
||||
assert_eq!(pending.status, LoginChallengeState::Pending);
|
||||
assert!(pending.maybe_signed_session.is_none());
|
||||
assert_eq!(post(&app, &h, "review", json!({"approval_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
// Swapping approval and device credentials cannot expose or redeem a session.
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post(&app, &h, "review", json!({"approval_token": created.device_token}), true).await.status(), StatusCode::UNAUTHORIZED);
|
||||
let reviewed: ReviewLoginChallengeResponse = test::read_body_json(post(&app, &h, "review", json!({"approval_token": approval}), true).await).await;
|
||||
assert_eq!(reviewed.requesting_ip, h.ip);
|
||||
assert_eq!(reviewed.confirmation_code, created.confirmation_code);
|
||||
assert_eq!(reviewed.username, h.user.username);
|
||||
|
||||
let approved = post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await;
|
||||
assert!(!approved.headers().contains_key("set-cookie"));
|
||||
let approved: LoginChallengeResponse = test::read_body_json(approved).await;
|
||||
assert_eq!(approved.status, LoginChallengeState::Approved);
|
||||
assert!(approved.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0, "Yes records consent only");
|
||||
|
||||
// Two polls overlap. Both must observe the single committed session.
|
||||
let (first, retry) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), post(&app, &h, "poll", json!({"device_token": created.device_token}), false));
|
||||
let cookie = first.response().cookies().find(|c| c.name() == "session").unwrap().into_owned();
|
||||
let first: LoginChallengeResponse = test::read_body_json(first).await;
|
||||
let retry: LoginChallengeResponse = test::read_body_json(retry).await;
|
||||
assert_eq!(first.status, LoginChallengeState::Redeemed);
|
||||
assert_eq!(first.maybe_signed_session, retry.maybe_signed_session, "response loss retries reuse the same session");
|
||||
assert_eq!(first.maybe_signed_session.as_deref(), Some(cookie.value()));
|
||||
assert_ne!(cookie.value(), h.cookie, "never copy the website's existing session");
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
assert!(fixtures::audit(&h.db.pool, &hash).await.session_id.is_some());
|
||||
let response = test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie.clone()).to_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let downstream: Value = test::read_body_json(response).await;
|
||||
assert_eq!(downstream["user_token"], h.user.user_token.as_str());
|
||||
|
||||
// The handoff expires in 20 minutes; a successfully issued session keeps its own lifetime.
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
let expired: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(expired.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(expired.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, UserLoginChallengeStatus::Redeemed);
|
||||
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie).to_request()).await.status(), StatusCode::OK);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn decline_expiry_and_invalid_consent_never_mint_sessions() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
for origin in [None, Some("https://evil.example"), Some("null")] {
|
||||
let mut request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval, "approve": true}));
|
||||
if let Some(origin) = origin {
|
||||
request = request.insert_header(("Origin", origin));
|
||||
}
|
||||
assert_eq!(test::call_service(&app, request.to_request()).await.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
let mcp = create_test_mcp_session(&h.db.pool, &h.user.user_token).await.unwrap();
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).insert_header(("Authorization", format!("Bearer {}", mcp.private_session_token.as_str()))).set_json(json!({"approval_token": approval, "approve": true})).to_request();
|
||||
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED);
|
||||
let declined: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true).await).await;
|
||||
assert_eq!(declined.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
// A later Yes cannot overwrite No.
|
||||
let replay: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await).await;
|
||||
assert_eq!(replay.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
let rejected: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(rejected.status, LoginChallengeState::Failed);
|
||||
assert!(rejected.maybe_signed_session.is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.creation_ip, h.ip);
|
||||
assert_eq!(audit.failure_ip.as_deref(), Some(BROWSER_IP));
|
||||
assert_eq!(audit.failure, Some(UserLoginChallengeFailureType::UserDeclined));
|
||||
assert!(audit.session_id.is_none());
|
||||
|
||||
for approve_first in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
if approve_first {
|
||||
assert_eq!(post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await.status(), StatusCode::OK);
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, 0).await;
|
||||
let result: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.status, UserLoginChallengeStatus::Failed);
|
||||
assert!(audit.failure_ip.is_none());
|
||||
}
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn expired_revoked_and_banned_sessions_fail_closed() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
for revoke in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
let redeemed: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
let signed = redeemed.maybe_signed_session.unwrap();
|
||||
let request = test::TestRequest::default().insert_header(("session", signed.as_str())).to_http_request();
|
||||
let token = h.signer.decode_session_payload_from_request(&request).unwrap().unwrap().session_token;
|
||||
if revoke {
|
||||
fixtures::revoke_session(&h.db.pool, &token).await;
|
||||
} else {
|
||||
fixtures::expire_session(&h.db.pool, &token).await;
|
||||
}
|
||||
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").insert_header(("session", signed)).to_request()).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let count = fixtures::bridge_session_count(&h.db.pool).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
fixtures::ban_user(&h.db.pool, &h.user.user_token).await;
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, count);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn expiry_is_checked_after_waiting_for_the_redemption_lock() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, 1).await;
|
||||
let mut lock = h.db.pool.begin().await.unwrap();
|
||||
lock_by_device(LockByDeviceArgs {
|
||||
device_hash: &hash,
|
||||
mysql_executor: &mut *lock,
|
||||
}).await.unwrap().unwrap();
|
||||
let (response, _) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), async {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
lock.commit().await.unwrap();
|
||||
});
|
||||
let result: LoginChallengeResponse = test::read_body_json(response).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn competing_decisions_are_immutable_and_abandoned_requests_expire() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/create").peer_addr("[2001:db8::42]:1234".parse().unwrap()).set_json(json!({})).to_request();
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(test::call_service(&app, request).await).await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.creation_ip, "2001:db8::42");
|
||||
let approval = approval_token(&created);
|
||||
let (yes, no) = tokio::join!(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true), post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true));
|
||||
let yes: LoginChallengeResponse = test::read_body_json(yes).await;
|
||||
let no: LoginChallengeResponse = test::read_body_json(no).await;
|
||||
assert_eq!(yes.status, no.status);
|
||||
assert_eq!(yes.maybe_failure_type, no.maybe_failure_type);
|
||||
assert!(matches!(yes.status, LoginChallengeState::Approved | LoginChallengeState::Failed));
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
// Sweep both pending and approved challenges even if the desktop never polls.
|
||||
for approve_first in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
if approve_first {
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
assert_eq!(expire_abandoned_challenges(ExpireAbandonedChallengesArgs {
|
||||
mysql_executor: &h.db.pool,
|
||||
}).await.unwrap(), 1);
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.failure, Some(UserLoginChallengeFailureType::Expired));
|
||||
assert!(audit.failure_ip.is_none());
|
||||
assert!(audit.session_id.is_none());
|
||||
}
|
||||
assert_eq!(expire_abandoned_challenges(ExpireAbandonedChallengesArgs {
|
||||
mysql_executor: &h.db.pool,
|
||||
}).await.unwrap(), 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn impersonated_expired_and_revoked_browser_sessions_cannot_approve() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
for mode in ["impersonated", "expired", "revoked"] {
|
||||
let user = create_test_user(&h.db.pool).await.unwrap();
|
||||
match mode {
|
||||
"impersonated" => fixtures::impersonate_session(&h.db.pool, user.session_token.as_str(), &h.user.user_token).await,
|
||||
"expired" => fixtures::expire_session(&h.db.pool, user.session_token.as_str()).await,
|
||||
_ => fixtures::revoke_session(&h.db.pool, user.session_token.as_str()).await,
|
||||
}
|
||||
let cookie = h.signer.create_cookie(&user.session_token, &user.user_token).unwrap();
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).cookie(cookie).set_json(json!({"approval_token": approval, "approve": true})).to_request();
|
||||
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED, "{mode}");
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, UserLoginChallengeStatus::Pending);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
db: IsolatedTestDatabase,
|
||||
user: TestUser,
|
||||
signer: HttpUserSessionManager,
|
||||
cookie: String,
|
||||
ip: String,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn create() -> Self {
|
||||
static NEXT_IP: AtomicU16 = AtomicU16::new(1);
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let user = create_test_user(&db.pool).await.unwrap();
|
||||
let signer = HttpUserSessionManager::new("localhost", "bridge_integration_test_secret").unwrap();
|
||||
let cookie = signer.create_cookie(&user.session_token, &user.user_token).unwrap().value().to_owned();
|
||||
let ip = format!("192.0.2.{}", NEXT_IP.fetch_add(1, Ordering::Relaxed));
|
||||
Self { db, user, signer, cookie, ip }
|
||||
}
|
||||
}
|
||||
|
||||
async fn post<S, B>(app: &S, h: &Harness, action: &str, body: impl Serialize, authenticated: bool) -> ServiceResponse<B>
|
||||
where
|
||||
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||
B: MessageBody,
|
||||
{
|
||||
let ip = if action == "review" || action == "decide" { BROWSER_IP } else { &h.ip };
|
||||
let mut request = test::TestRequest::post().uri(&format!("/v1/login_challenges/{action}")).insert_header(("Origin", ORIGIN)).peer_addr(format!("{ip}:1234").parse().unwrap()).set_json(body);
|
||||
if authenticated {
|
||||
request = request.insert_header(("session", h.cookie.as_str()));
|
||||
}
|
||||
test::call_service(app, request.to_request()).await
|
||||
}
|
||||
|
||||
fn approval_token(created: &CreateLoginChallengeResponse) -> String {
|
||||
created.verification_url.split_once("#approval_token=").unwrap().1.to_owned()
|
||||
}
|
||||
|
||||
/// Probe the SAME production authentication helper used by protected endpoints,
|
||||
/// with the cookie signer and SQL lookups, rather than merely decoding a JWT.
|
||||
async fn protected(request: HttpRequest, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<Value>, CommonWebError> {
|
||||
let checker = SessionChecker::new(&signer);
|
||||
let user = require_user_session(&request, &checker, &**pool).await?;
|
||||
Ok(web::Json(json!({"user_token": user.user_token, "username": user.username})))
|
||||
}
|
||||
+32
-100
@@ -3,7 +3,6 @@ use std::sync::Arc;
|
||||
use actix_web::web::{Json, Path, Query};
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use artcraft_api_defs::common::responses::media_links::MediaLinks;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
@@ -15,14 +14,17 @@ use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use log::{info, warn};
|
||||
use log::info;
|
||||
use std::time::Instant;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::count_media_files_for_user::{count_media_files_for_user, CountMediaFilesForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::http_server::common_responses::pagination_page::PaginationPage;
|
||||
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
|
||||
@@ -30,7 +32,6 @@ use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_m
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::allowed_studio_access::allowed_studio_access;
|
||||
|
||||
@@ -238,117 +239,48 @@ pub async fn list_media_files_for_user_handler(
|
||||
let mut maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
|
||||
let mut maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
|
||||
|
||||
info!("Querying media files for user: {:?} type: {:?} as: {:?}", path.username, maybe_filter_media_types, view_as);
|
||||
|
||||
let query_results = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
let filters = UserMediaFileFilters {
|
||||
username: &path.username,
|
||||
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
|
||||
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
|
||||
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
|
||||
include_user_uploads: query.include_user_uploads.unwrap_or(false),
|
||||
page_size,
|
||||
page_index,
|
||||
sort_ascending,
|
||||
view_as,
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
}).await;
|
||||
|
||||
let results_page = match query_results {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
warn!("Query error: {:?}", e);
|
||||
return Err(CommonWebError::from_anyhow_error(e));
|
||||
}
|
||||
};
|
||||
let count_started = Instant::now();
|
||||
let total_count = count_media_files_for_user(CountMediaFilesForUserArgs {
|
||||
filters,
|
||||
mysql_executor: &server_state.mysql_pool,
|
||||
}).await?;
|
||||
let count_ms = count_started.elapsed().as_millis();
|
||||
let page_started = Instant::now();
|
||||
let records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: page_size,
|
||||
offset: page_index * page_size,
|
||||
sort_ascending,
|
||||
mysql_executor: &server_state.mysql_pool,
|
||||
}).await?;
|
||||
info!("Listed legacy user media: username={} page={} count_ms={} page_ms={}",
|
||||
path.username, page_index, count_ms, page_started.elapsed().as_millis());
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
let results = results_page.records.into_iter()
|
||||
.filter(|record| {
|
||||
if is_allowed_studio_access {
|
||||
return true;
|
||||
}
|
||||
// Don't allow access to certain media types.
|
||||
match record.media_type {
|
||||
MediaFileType::Bvh |
|
||||
MediaFileType::Fbx |
|
||||
MediaFileType::Glb |
|
||||
MediaFileType::Gltf |
|
||||
MediaFileType::SceneRon => return false,
|
||||
_ => {},
|
||||
}
|
||||
// // Don't allow access to certain products.
|
||||
// match record.origin_product_category {
|
||||
// MediaFileOriginProductCategory::VideoFilter |
|
||||
// MediaFileOriginProductCategory::Mocap |
|
||||
// MediaFileOriginProductCategory::Workflow => return false,
|
||||
// _ => {},
|
||||
// }
|
||||
true
|
||||
})
|
||||
.map(|record| {
|
||||
let public_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&record.public_bucket_directory_hash,
|
||||
record.maybe_public_bucket_prefix.as_deref(),
|
||||
record.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
MediaFileForUserListItem {
|
||||
token: record.token.clone(),
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin: MediaFileOriginDetails::from_db_fields_str(
|
||||
record.origin_category,
|
||||
record.origin_product_category,
|
||||
record.maybe_origin_model_type,
|
||||
record.maybe_origin_model_token.as_deref(),
|
||||
record.maybe_origin_model_title.as_deref()),
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain, server_state.server_environment, &public_bucket_path),
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
public_bucket_path: public_bucket_path
|
||||
.get_full_object_path_str()
|
||||
.to_string(),
|
||||
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_state.server_environment),
|
||||
cover_image: MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&record.token,
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
|
||||
),
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: record.is_user_upload,
|
||||
is_intermediate_system_file: record.is_intermediate_system_file,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_style_name: record.maybe_prompt_args
|
||||
.as_ref()
|
||||
.and_then(|args| args.style_name.as_ref())
|
||||
.and_then(|style| style.to_style_name()),
|
||||
maybe_duration_millis: record.maybe_duration_millis,
|
||||
stats: SimpleEntityStats {
|
||||
positive_rating_count: record.maybe_ratings_positive_count.unwrap_or(0),
|
||||
bookmark_count: record.maybe_bookmark_count.unwrap_or(0),
|
||||
},
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let results = build_user_media_file_response(UserMediaFileResponseArgs {
|
||||
records,
|
||||
is_allowed_studio_access,
|
||||
media_domain,
|
||||
server_environment: server_state.server_environment,
|
||||
});
|
||||
|
||||
Ok(Json(ListMediaFilesForUserSuccessResponse {
|
||||
success: true,
|
||||
results,
|
||||
pagination: PaginationPage {
|
||||
current: results_page.current_page,
|
||||
total_page_count: results_page.total_page_count,
|
||||
current: page_index,
|
||||
// Preserve v1's historical calculation, including exact multiples.
|
||||
// A pagination behavior change belongs in a new endpoint version.
|
||||
total_page_count: 1 + (total_count / page_size as i64) as usize,
|
||||
}
|
||||
}))
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use actix_web::web::{Json, Path, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use enums::common::view_as::ViewAs;
|
||||
use log::info;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
|
||||
list_media_files_for_user, ListMediaFileForUserArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use super::list_media_files_for_user_handler::MediaFileForUserListItem;
|
||||
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::allowed_studio_access::allowed_studio_access;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2PathInfo {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMediaFilesForUserV2QueryParams {
|
||||
pub sort_ascending: Option<bool>,
|
||||
pub page_size: Option<usize>,
|
||||
pub page_index: Option<usize>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileClass`,
|
||||
/// which are the broad category of media files: image, video, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_classes=audio`
|
||||
/// - `?filter_media_classes=image,video`
|
||||
/// - `?filter_media_classes=dimensional`
|
||||
/// - etc.
|
||||
pub filter_media_classes: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileType`,
|
||||
/// which are mimetype-like / format-like categories of media files: glb, gltf,
|
||||
/// scene_json, jpg, png, mp4, wav, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_type=scene_json`
|
||||
/// - `?filter_media_type=glb,gltf,fbx`
|
||||
/// - `?filter_media_type=pmd,vmd,pmx`
|
||||
/// - `?filter_media_type=jpg,png,gif`
|
||||
/// - `?filter_media_type=wav,mp3`
|
||||
/// - `?filter_media_type=mp4`
|
||||
/// - etc.
|
||||
pub filter_media_type: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileEngineCategory`.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_engine_categories=scene`
|
||||
/// - `?filter_engine_categories=animation,character,object`
|
||||
/// - etc.
|
||||
pub filter_engine_categories: Option<String>,
|
||||
|
||||
/// Include user uploaded files in the results.
|
||||
/// By default, we do not return them unless this flag is set to true.
|
||||
pub include_user_uploads: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2SuccessResponse {
|
||||
pub success: bool,
|
||||
pub results: Vec<MediaFileForUserListItem>,
|
||||
pub pagination: UserMediaFileV2Pagination,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserMediaFileV2Pagination {
|
||||
pub current: usize,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Faster user-media listing for infinite scroll.
|
||||
///
|
||||
/// Unlike `/v1/media_files/list/user/{username}`, this endpoint skips the exact
|
||||
/// `COUNT(*)` over the user's entire matching library. It fetches `page_size + 1`
|
||||
/// records to return `pagination.has_more`, without a total-page count. Removing
|
||||
/// that scan makes loading pages faster for large libraries.
|
||||
///
|
||||
/// Both endpoints use the same optimized per-user indexes and load related
|
||||
/// metadata only after selecting a page. The original endpoint retains its
|
||||
/// request/response contract and pagination behavior for deployed clients.
|
||||
///
|
||||
/// Page indexes remain offset based; very deep pages still scan preceding
|
||||
/// matching records. Page size defaults to 25 and must be between 1 and 100.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Media Files",
|
||||
path = "/v1/media_files/list_v2/user/{username}",
|
||||
params(ListMediaFilesForUserV2QueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Faster user-media page with has_more; no full-library count", body = ListMediaFilesForUserV2SuccessResponse),
|
||||
(status = 400, description = "Invalid pagination"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn list_media_files_for_user_v2_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListMediaFilesForUserV2PathInfo>,
|
||||
query: Query<ListMediaFilesForUserV2QueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMediaFilesForUserV2SuccessResponse>, CommonWebError> {
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await?;
|
||||
|
||||
let mut is_author = false;
|
||||
let mut is_mod = false;
|
||||
|
||||
// NB: Temporary rollout flag for certain file types (BVH, etc).
|
||||
let is_allowed_studio_access = allowed_studio_access(maybe_user_session.as_ref(), &server_state.flags);
|
||||
|
||||
match maybe_user_session {
|
||||
None => {}
|
||||
Some(session) => {
|
||||
is_author = session.username == path.username;
|
||||
is_mod = session.can_ban_users;
|
||||
}
|
||||
};
|
||||
|
||||
let sort_ascending = query.sort_ascending.unwrap_or(false);
|
||||
let page_size = query.page_size.unwrap_or_else(|| 25);
|
||||
let page_index = query.page_index.unwrap_or_else(|| 0);
|
||||
if !(1..=100).contains(&page_size) {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"page_size must be between 1 and 100".to_string(),
|
||||
));
|
||||
}
|
||||
let offset = page_index
|
||||
.checked_mul(page_size)
|
||||
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("page_index is too large".to_string()))?;
|
||||
|
||||
let view_as = if is_author {
|
||||
ViewAs::Author
|
||||
} else if is_mod {
|
||||
ViewAs::Moderator
|
||||
} else {
|
||||
ViewAs::AnotherUser
|
||||
};
|
||||
|
||||
let maybe_filter_media_types = get_scoped_media_types(query.filter_media_type.as_deref());
|
||||
let maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
|
||||
let maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
|
||||
|
||||
let filters = UserMediaFileFilters {
|
||||
username: &path.username,
|
||||
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
|
||||
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
|
||||
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
|
||||
include_user_uploads: query.include_user_uploads.unwrap_or(false),
|
||||
view_as,
|
||||
};
|
||||
let page_started = Instant::now();
|
||||
let mut records = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
filters,
|
||||
limit: page_size + 1,
|
||||
offset,
|
||||
sort_ascending,
|
||||
mysql_executor: &server_state.mysql_pool,
|
||||
})
|
||||
.await?;
|
||||
let has_more = records.len() > page_size;
|
||||
records.truncate(page_size);
|
||||
info!(
|
||||
"Listed list_v2 user media: username={} page={} page_ms={}",
|
||||
path.username,
|
||||
page_index,
|
||||
page_started.elapsed().as_millis()
|
||||
);
|
||||
let results = build_user_media_file_response(UserMediaFileResponseArgs {
|
||||
records,
|
||||
is_allowed_studio_access,
|
||||
media_domain: get_media_domain(&http_request),
|
||||
server_environment: server_state.server_environment,
|
||||
});
|
||||
Ok(Json(ListMediaFilesForUserV2SuccessResponse {
|
||||
success: true,
|
||||
results,
|
||||
pagination: UserMediaFileV2Pagination {
|
||||
current: page_index,
|
||||
has_more,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -6,3 +6,5 @@ pub mod list_media_files_for_user_handler;
|
||||
pub mod list_media_files_handler;
|
||||
pub mod list_pinned_media_files_handler;
|
||||
pub mod list_session_project_media_files_handler;
|
||||
pub mod list_media_files_for_user_v2_handler;
|
||||
mod user_media_file_response;
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use mysql_queries::queries::media_files::list::user_media_file_list_item::MediaFileListItem;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use super::list_media_files_for_user_handler::MediaFileForUserListItem;
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
|
||||
|
||||
pub(super) struct UserMediaFileResponseArgs {
|
||||
pub records: Vec<MediaFileListItem>,
|
||||
pub is_allowed_studio_access: bool,
|
||||
pub media_domain: MediaDomain,
|
||||
pub server_environment: ServerEnvironment,
|
||||
}
|
||||
|
||||
/// Shared media-item representation; pagination contracts remain versioned.
|
||||
pub(super) fn build_user_media_file_response(args: UserMediaFileResponseArgs) -> Vec<MediaFileForUserListItem> {
|
||||
let UserMediaFileResponseArgs {
|
||||
records,
|
||||
is_allowed_studio_access,
|
||||
media_domain,
|
||||
server_environment,
|
||||
} = args;
|
||||
records
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
if is_allowed_studio_access {
|
||||
return true;
|
||||
}
|
||||
// Don't allow access to certain media types.
|
||||
match record.media_type {
|
||||
MediaFileType::Bvh
|
||||
| MediaFileType::Fbx
|
||||
| MediaFileType::Glb
|
||||
| MediaFileType::Gltf
|
||||
| MediaFileType::SceneRon => return false,
|
||||
_ => {}
|
||||
}
|
||||
// // Don't allow access to certain products.
|
||||
// match record.origin_product_category {
|
||||
// MediaFileOriginProductCategory::VideoFilter |
|
||||
// MediaFileOriginProductCategory::Mocap |
|
||||
// MediaFileOriginProductCategory::Workflow => return false,
|
||||
// _ => {},
|
||||
// }
|
||||
true
|
||||
})
|
||||
.map(|record| {
|
||||
let public_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&record.public_bucket_directory_hash,
|
||||
record.maybe_public_bucket_prefix.as_deref(),
|
||||
record.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
MediaFileForUserListItem {
|
||||
token: record.token.clone(),
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin: MediaFileOriginDetails::from_db_fields_str(
|
||||
record.origin_category,
|
||||
record.origin_product_category,
|
||||
record.maybe_origin_model_type,
|
||||
record.maybe_origin_model_token.as_deref(),
|
||||
record.maybe_origin_model_title.as_deref(),
|
||||
),
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(media_domain, server_environment, &public_bucket_path),
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
public_bucket_path: public_bucket_path.get_full_object_path_str().to_string(),
|
||||
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_environment),
|
||||
cover_image: MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&record.token,
|
||||
media_domain,
|
||||
server_environment,
|
||||
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
|
||||
),
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: record.is_user_upload,
|
||||
is_intermediate_system_file: record.is_intermediate_system_file,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_style_name: record
|
||||
.maybe_prompt_args
|
||||
.as_ref()
|
||||
.and_then(|args| args.style_name.as_ref())
|
||||
.and_then(|style| style.to_style_name()),
|
||||
maybe_duration_millis: record.maybe_duration_millis,
|
||||
stats: SimpleEntityStats {
|
||||
positive_rating_count: record.maybe_ratings_positive_count.unwrap_or(0),
|
||||
bookmark_count: record.maybe_bookmark_count.unwrap_or(0),
|
||||
},
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod image_studio;
|
||||
pub mod inference_job;
|
||||
pub mod internal;
|
||||
pub mod mcp_sessions;
|
||||
pub mod login_challenges;
|
||||
pub mod media_files;
|
||||
pub mod misc;
|
||||
pub mod model_download;
|
||||
|
||||
+1
-1
@@ -548,7 +548,7 @@ fn fixture_video_bytes(duration_millis: u64) -> Vec<u8> {
|
||||
/// the stub Kinovi server. Everything else is inert dummy configuration:
|
||||
/// Redis/Elasticsearch/Stripe/bucket clients are constructed lazily and are
|
||||
/// never called on the video generate path.
|
||||
fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
|
||||
pub(crate) fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
|
||||
let session_cookie_manager = HttpUserSessionManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET)
|
||||
.expect("session cookie manager");
|
||||
let avt_cookie_manager =
|
||||
|
||||
+5
@@ -17,6 +17,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::list_media_files_for_user_v2_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler;
|
||||
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler;
|
||||
use crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler;
|
||||
@@ -115,6 +116,10 @@ pub fn add_media_file_routes<T, B> (app: App<T>) -> App<T>
|
||||
.route(web::get().to(list_media_files_for_user_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/list_v2/user/{username}")
|
||||
.route(web::get().to(list_media_files_for_user_v2_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/mesh/list")
|
||||
.route(web::get().to(list_session_mesh_media_files_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
|
||||
@@ -28,4 +28,6 @@ mod wallet_routes;
|
||||
mod web_referrals_routes;
|
||||
mod webhook_routes;
|
||||
mod weights_routes;
|
||||
pub (super) mod add_application_routes;
|
||||
pub (super) mod add_application_routes;
|
||||
#[cfg(test)]
|
||||
mod user_media_endpoint_tests;
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
//! Real routes, signed sessions, and migrated local database fixtures.
|
||||
//! The fixed-socket isolated harness cannot use production database URLs.
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_http::Request;
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{Service, ServiceResponse};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App};
|
||||
use mysql_testing::fixtures::media_files::create_test_video_media_file;
|
||||
use mysql_testing::fixtures::users::create_test_user;
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::media_files_routes::add_media_file_routes;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::build_test_server_state;
|
||||
|
||||
const LEGACY_ITEM_FIELDS: &[&str] = &[
|
||||
"token",
|
||||
"media_class",
|
||||
"media_type",
|
||||
"maybe_engine_category",
|
||||
"maybe_animation_type",
|
||||
"origin",
|
||||
"origin_category",
|
||||
"origin_product_category",
|
||||
"maybe_origin_model_type",
|
||||
"maybe_origin_model_token",
|
||||
"public_bucket_path",
|
||||
"public_bucket_url",
|
||||
"media_links",
|
||||
"maybe_prompt_token",
|
||||
"cover_image",
|
||||
"creator_set_visibility",
|
||||
"is_user_upload",
|
||||
"is_intermediate_system_file",
|
||||
"maybe_title",
|
||||
"maybe_text_transcript",
|
||||
"maybe_style_name",
|
||||
"maybe_duration_millis",
|
||||
"stats",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
];
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn user_media_endpoint_versions_preserve_legacy_contract() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let owner = create_test_user(&db.pool).await.unwrap();
|
||||
for _ in 0..26 {
|
||||
create_test_video_media_file(&db.pool, &owner.user_token, Some(1234))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let state = build_test_server_state(db.pool.clone(), "http://127.0.0.1:1".to_string());
|
||||
let cookie = state
|
||||
.session_cookie_manager
|
||||
.create_cookie(&owner.session_token, &owner.user_token)
|
||||
.unwrap()
|
||||
.into_owned();
|
||||
let app = test::init_service(add_media_file_routes(
|
||||
App::new().app_data(web::Data::new(Arc::new(state))),
|
||||
))
|
||||
.await;
|
||||
let v1 = format!("/v1/media_files/list/user/{}", owner.username);
|
||||
let v2 = format!("/v1/media_files/list_v2/user/{}", owner.username);
|
||||
let session = Some(cookie.value());
|
||||
|
||||
// Default upload exclusion, page size, and original JSON shape stay intact.
|
||||
let (status, excluded) = get(&app, &v1, session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
json!({"success": true, "results": [], "pagination": {"current": 0, "total_page_count": 1}})
|
||||
);
|
||||
let (status, legacy) = get(&app, &format!("{v1}?include_user_uploads=true"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(legacy.as_object().unwrap().len(), 3);
|
||||
assert_eq!(legacy["results"].as_array().unwrap().len(), 25);
|
||||
assert_eq!(legacy["pagination"], json!({"current": 0, "total_page_count": 2}));
|
||||
assert_legacy_item_keys(&legacy["results"][0]);
|
||||
|
||||
// Exact multiples intentionally keep v1's historical extra page.
|
||||
let (_, exact) = get(&app, &format!("{v1}?include_user_uploads=true&page_size=26"), session).await;
|
||||
assert_eq!(exact["results"].as_array().unwrap().len(), 26);
|
||||
assert_eq!(exact["pagination"], json!({"current": 0, "total_page_count": 2}));
|
||||
// V2's size cap must never spill into the legacy endpoint.
|
||||
let (status, large) = get(&app, &format!("{v1}?include_user_uploads=true&page_size=101"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(large["results"].as_array().unwrap().len(), 26);
|
||||
assert_eq!(large["pagination"], json!({"current": 0, "total_page_count": 1}));
|
||||
let (_, last) = get(&app, &format!("{v1}?include_user_uploads=true&page_index=1"), session).await;
|
||||
assert_eq!(last["results"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(last["pagination"], json!({"current": 1, "total_page_count": 2}));
|
||||
// The rejected count-mode option has no effect on the v1 contract.
|
||||
let (_, ignored_option) = get(
|
||||
&app,
|
||||
&format!("{v1}?include_user_uploads=true&include_total_count=false"),
|
||||
session,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(ignored_option, legacy);
|
||||
|
||||
let (status, first_v2) = get(&app, &format!("{v2}?include_user_uploads=true"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(first_v2.as_object().unwrap().len(), 3);
|
||||
assert_eq!(first_v2["results"], legacy["results"]);
|
||||
assert_eq!(first_v2["pagination"], json!({"current": 0, "has_more": true}));
|
||||
let (_, last_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=1"), session).await;
|
||||
assert_eq!(last_v2["results"], last["results"]);
|
||||
assert_eq!(last_v2["pagination"], json!({"current": 1, "has_more": false}));
|
||||
let (_, exact_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_size=26"), session).await;
|
||||
assert_eq!(exact_v2["pagination"], json!({"current": 0, "has_more": false}));
|
||||
let (_, empty_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=2"), session).await;
|
||||
assert_eq!(empty_v2["results"], json!([]));
|
||||
assert_eq!(empty_v2["pagination"], json!({"current": 2, "has_more": false}));
|
||||
|
||||
for operation in ["list", "list_v2"] {
|
||||
let path = format!(
|
||||
"/v1/media_files/{operation}/user/{}?include_user_uploads=true",
|
||||
owner.username
|
||||
);
|
||||
let (status, anonymous) = get(&app, &path, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
anonymous["results"],
|
||||
json!([]),
|
||||
"private fixtures must require their owner session"
|
||||
);
|
||||
let (status, missing) = get(
|
||||
&app,
|
||||
&format!("/v1/media_files/{operation}/user/missing_fixture?include_user_uploads=true"),
|
||||
session,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(missing["results"], json!([]));
|
||||
let (status, filtered) = get(&app, &format!("{path}&filter_media_classes=image"), session).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(filtered["results"], json!([]));
|
||||
}
|
||||
for query in [
|
||||
"page_size=0".to_string(),
|
||||
"page_size=101".to_string(),
|
||||
format!("page_size=60&page_index={}", usize::MAX),
|
||||
] {
|
||||
let (status, _) = get(&app, &format!("{v2}?{query}"), session).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
drop(app);
|
||||
db.destroy().await;
|
||||
}
|
||||
|
||||
async fn get<S, B>(app: &S, path: &str, maybe_session: Option<&str>) -> (StatusCode, Value)
|
||||
where
|
||||
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||
B: MessageBody,
|
||||
B::Error: std::fmt::Debug,
|
||||
{
|
||||
let mut request = test::TestRequest::get().uri(path);
|
||||
if let Some(session) = maybe_session {
|
||||
request = request.insert_header(("session", session));
|
||||
}
|
||||
let response = test::call_service(app, request.to_request()).await;
|
||||
let status = response.status();
|
||||
(status, test::read_body_json(response).await)
|
||||
}
|
||||
|
||||
fn assert_legacy_item_keys(item: &Value) {
|
||||
let mut actual = item.as_object().unwrap().keys().map(String::as_str).collect::<Vec<_>>();
|
||||
actual.sort_unstable();
|
||||
let mut expected = LEGACY_ITEM_FIELDS.to_vec();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
+1
@@ -32,6 +32,7 @@ pub fn add_user_routes<T, B> (app: App<T>) -> App<T>
|
||||
>,
|
||||
{
|
||||
app
|
||||
.configure(crate::http_server::endpoints::login_challenges::handlers::configure)
|
||||
.service(
|
||||
// TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal
|
||||
web::resource("/create_account")
|
||||
|
||||
@@ -55,6 +55,7 @@ use crate::startup::setup_disabled_endpoints::read_disabled_endpoints;
|
||||
use crate::startup::setup_metrics::build_metrics;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
|
||||
use crate::threads::expire_login_challenges::expire_login_challenges;
|
||||
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
|
||||
use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
|
||||
|
||||
@@ -108,6 +109,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
// ==================== Background threads ==================== //
|
||||
|
||||
let tokio_runtime = Runtime::new()?;
|
||||
tokio_runtime.spawn(expire_login_challenges(server_state.mysql_pool.clone()));
|
||||
|
||||
info!("Spawning pager worker thread.");
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::warn;
|
||||
use mysql_queries::queries::user_login_challenges::expire_abandoned_challenges::{expire_abandoned_challenges, ExpireAbandonedChallengesArgs};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub async fn expire_login_challenges(pool: MySqlPool) {
|
||||
loop {
|
||||
if let Err(error) = expire_abandoned_challenges(ExpireAbandonedChallengesArgs {
|
||||
mysql_executor: &pool,
|
||||
}).await {
|
||||
warn!("Login challenge expiration sweep failed: {}", error);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod db_health_checker_thread;
|
||||
pub mod expire_login_challenges;
|
||||
pub mod poll_ip_banlist_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
@@ -0,0 +1,101 @@
|
||||
# User media library query
|
||||
|
||||
`GET /v1/media_files/list/user/{username}` runs an exact count before fetching
|
||||
each page. The webapp continues to use it for infinite scroll. MySQL can choose
|
||||
an index-merge plan that intersects the creator index with global indexes on
|
||||
NULL deletion timestamps and the intermediate-file flag. That makes the count
|
||||
scan unrelated users' index entries.
|
||||
|
||||
The queries now force the creator index for counts and the creator/date index
|
||||
for page selection. A limited derived table selects IDs before joining prompt,
|
||||
cover, model, and stats data. Ordering uses `(created_at, id)` to break timestamp
|
||||
ties consistently. Shared predicates preserve author/moderator/public visibility,
|
||||
deleted-file exclusions, uploads, and class/type/engine filters.
|
||||
|
||||
The legacy `GET /v1/media_files/list/user/{username}` contract is unchanged:
|
||||
original query fields and defaults, no new page-size cap, and the original
|
||||
`pagination: { current, total_page_count }` with a numeric total. Its historical
|
||||
`1 + floor(count / page_size)` calculation is intentionally preserved, including
|
||||
exact multiples. It always performs the optimized exact count.
|
||||
|
||||
The separate `GET /v1/media_files/list_v2/user/{username}` endpoint is available
|
||||
for adoption after backend deployment. `list_v2` fetches one extra record
|
||||
and returns `pagination: { current, has_more }`. It never counts the library and
|
||||
has no count-mode flag or total-page field. `list_v2` accepts page sizes from 1 to 100.
|
||||
Both endpoints return the same media-item fields and enforce the same visibility
|
||||
and media filters.
|
||||
|
||||
Skipping the count is the additional speed improvement: even the optimized
|
||||
legacy count must visit all matching rows before returning an exact total. The
|
||||
new endpoint avoids that work on every request, including the first page. For a
|
||||
60-item page it fetches at most 61 result records; it can still scan additional
|
||||
index entries to apply filters or skip an offset. Compatible index-selection
|
||||
and deferred-join improvements remain shared by both endpoints.
|
||||
|
||||
Pagination remains offset based, so very deep pages still scan preceding
|
||||
matching entries. `list_v2` removes the full-library count from infinite-scroll
|
||||
requests, but does not remove that offset cost.
|
||||
|
||||
## Deployment
|
||||
|
||||
The frontend switch is deferred. The webapp continues using
|
||||
`GalleryModalApi.listUserMediaFiles` and the existing
|
||||
`/v1/media_files/list/user/{username}` endpoint with numbered pagination.
|
||||
The `UserMediaFilesV2Api` library binding and its tests are retained, with no live
|
||||
frontend callers.
|
||||
|
||||
Deploy and verify storyteller-web with the new `list_v2` route before a separate
|
||||
frontend change adopts `UserMediaFilesV2Api` and its `has_more` pagination.
|
||||
Both routes remain in API v1; this is an endpoint-local revision. Existing
|
||||
clients and the original bindings remain unchanged.
|
||||
|
||||
No new migration is needed. The existing migration
|
||||
`2026-07-03-074740-0000_alter_media_files_add_user_created_at_index`
|
||||
must already be applied: both page queries require `idx_creator_created_at`.
|
||||
The legacy count query uses the longstanding `fk_maybe_creator_user_token` index.
|
||||
|
||||
Endpoint logs distinguish legacy/list_v2 and report `page_ms`; the legacy route
|
||||
also reports `count_ms`.
|
||||
Use those timings to verify the actual production improvement after deployment.
|
||||
|
||||
## Local validation
|
||||
|
||||
Database tests use `IsolatedTestDatabase`: a fixed `/tmp/mysql.sock`, new random
|
||||
schemas, and checked-in migrations. They do not read production connection URLs
|
||||
or modify existing schemas. From the repository root:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library
|
||||
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library -- --ignored --nocapture
|
||||
```
|
||||
|
||||
The large fixture creates 300,000 rows, 60,000 owned by the target account. On
|
||||
local MySQL 8.4.11, the production page query returned 61 rows in 3.4 ms with 91
|
||||
sequential index reads. Its test asserts bounded reads rather than wall-clock
|
||||
latency. The legacy user-scoped count took 38.5 ms.
|
||||
|
||||
A separate local EXPLAIN ANALYZE comparison with that distribution measured the
|
||||
old count at 166 ms and the forced creator-index count at 41.5 ms. The old plan
|
||||
scanned 300,000 entries in each of three global indexes. These are synthetic
|
||||
local measurements, not production response-time promises.
|
||||
|
||||
Functional fixtures cover public/private/hidden visibility, other owners,
|
||||
deleted and intermediate files, all library media classes including legacy 3D,
|
||||
upload/type/engine filters, same-timestamp pagination, joined metadata, unknown
|
||||
usernames, and caller-owned transactions.
|
||||
|
||||
The real Actix route test uses signed sessions and disposable MySQL fixtures to
|
||||
lock down the original request defaults, response keys/types, exact-multiple page counts,
|
||||
and page sizes above 100. It also checks `list_v2` first/last/empty pages, validation,
|
||||
media-item parity, filters, and private-file access:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test -p storyteller-web --bin storyteller-web user_media_endpoint_versions
|
||||
```
|
||||
|
||||
Frontend HTTP-binding tests:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
node_modules/.bin/vitest run --config libs/api/vite.config.ts src/lib/MediaFilesApi.spec.ts
|
||||
```
|
||||
@@ -2,17 +2,24 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
if (window.location.pathname === "/login/desktop") {
|
||||
const policy = document.createElement("meta");
|
||||
policy.name = "referrer";
|
||||
policy.content = "no-referrer";
|
||||
document.head.appendChild(policy);
|
||||
} else {
|
||||
const analytics = document.createElement("script");
|
||||
analytics.async = true;
|
||||
analytics.src = "https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0";
|
||||
document.head.appendChild(analytics);
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
}
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -48,6 +48,7 @@ import { SignupCtaModal } from "../components/signup-cta-modal";
|
||||
import { InsufficientCreditsModal } from "../components/insufficient-credits-modal";
|
||||
import { GlobalActionReminder } from "../components/global-action-reminder";
|
||||
import { useSession } from "../lib/session";
|
||||
import { DesktopLogin } from "../pages/login/desktop-login";
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -144,6 +145,7 @@ export function App() {
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route path="/login/desktop" element={<DesktopLogin />} />
|
||||
{/* Public — no chrome, no auth gate. login/signup share a layout route
|
||||
so the showcase pane (and its video) stays mounted when toggling
|
||||
between them. */}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AUTH_FORM_PADDING } from "./auth-form-styles";
|
||||
import { ReactNode } from "react";
|
||||
import { Outlet, Navigate, Link } from "react-router-dom";
|
||||
import { Outlet, Navigate, Link, useSearchParams } from "react-router-dom";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { AuthShowcase } from "./auth-showcase";
|
||||
import { AuthPageFrame } from "./auth-page-frame";
|
||||
import { useMediaQuery } from "../ui/use-media-query";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
|
||||
/**
|
||||
* Persistent shell for the auth pages. Rendered as a layout route so the
|
||||
@@ -15,14 +16,15 @@ import { useSession } from "../../lib/session";
|
||||
* (AuthHeader) and footer (AuthFooter) into the outlet.
|
||||
*/
|
||||
export const AuthLayout = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
// Only mount the showcase on wide screens (matches the `lg` breakpoint) so
|
||||
// mobile never downloads the demo videos.
|
||||
const showShowcase = useMediaQuery("(min-width: 1024px)");
|
||||
const { loggedIn, authChecked } = useSession();
|
||||
|
||||
// Already signed in? Never show the auth form — go straight home.
|
||||
// Preserve a safe return path when session refresh completes during login.
|
||||
if (loggedIn) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to={safeAuthReturnPath(searchParams.get("from"))} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,8 @@ interface SignupFormProps {
|
||||
signupSource: string;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
/** Let the enclosing flow resume after Google, without a billing redirect. */
|
||||
continueAfterGoogleLogin?: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -28,6 +30,7 @@ export const SignupForm = ({
|
||||
signupSource,
|
||||
className = "",
|
||||
autoFocus = false,
|
||||
continueAfterGoogleLogin = false,
|
||||
}: SignupFormProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -84,6 +87,10 @@ export const SignupForm = ({
|
||||
};
|
||||
|
||||
const handleGoogleSuccess = async () => {
|
||||
if (continueAfterGoogleLogin) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
// Refresh the session and check the subscription in parallel; users without
|
||||
// an active subscription are pushed to pricing, subscribers go home.
|
||||
const [, subscribed] = await Promise.all([
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
//const WINDOWS_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
|
||||
//const WINDOWS_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
|
||||
//const WINDOWS_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
|
||||
const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
//const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
const WINDOWS_VERSION = "0.41.0";
|
||||
|
||||
//const MAC_VERSION = "0.7.0"; // 2026-02-23 Seedance
|
||||
//const MAC_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
|
||||
//const MAC_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
|
||||
//const MAC_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
|
||||
const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
//const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
const MAC_VERSION = "0.41.0";
|
||||
|
||||
export const DOWNLOAD_LINKS = {
|
||||
WINDOWS: `https://github.com/storytold/artcraft/releases/download/artcraft-v${WINDOWS_VERSION}/ArtCraft_${WINDOWS_VERSION}_x64-setup.exe`,
|
||||
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user