Compare commits

...
Author SHA1 Message Date
Brandon Thomas 293a41c7d4 let's actually not call the new endpoint prematurely 2026-09-27 17:35:00 -04:00
Brandon Thomas 55ab800542 more performant list user files endpoint 2026-09-27 15:02:19 -04:00
Brandon Thomas 2c93b4cf42 Merge pull request #36 from storytold/bt-opengraph-metadata
add opengraph metadata (social media tags)
2026-09-26 16:32:39 -07:00
Brandon Thomas 9169e4d662 add opengraph metadata (social media tags) 2026-09-26 16:30:57 -07:00
Brandon Thomas 4c7af51851 Merge pull request #35 from storytold/bt-media-previews
fix media previews in new design
2026-09-26 10:30:44 -07:00
Brandon Thomas eadfb4f821 Merge origin/main into bt-media-previews
Combine the media share implementations, preserving interactive players, social cards, model/provider labels, reference thumbnails, and legacy redirects. Verify with 17 isolated Chrome integration tests, TypeScript checks, and the production build.
2026-09-26 09:58:16 -07:00
Brandon Thomas 0c163d6c19 routes 2026-09-26 09:33:04 -07:00
Brandon Thomas 26484685ee media previews on website 2026-09-26 09:25:40 -07:00
BFlat 5bceb33801 Website: model/provider names and icons, video reference thumbs on share page (#34)
Ports the model-list display names and brand marks, shows the Created
row as date + time like the webapp lightbox, and uses the still-frame
preview for video references.
2026-09-26 21:49:03 +07:00
BFlat c6e2c4296a Website: port media share page (/media/[token]) to artcraft-website-next (#33)
* Website: port the media share page to artcraft-website-next

Adds the /media/[token] route with server-side OG metadata, the media
viewer with prompt/reference/info details, and legacy ?media= redirects.

* no ruler on media share page
2026-09-26 21:36:04 +07:00
Brandon Thomas 8551d58def fix download version again 2026-09-26 04:32:59 -07:00
Brandon Thomas 807180bf22 Merge pull request #32 from storytold/bt-new-website-downloads
new website downloads
2026-09-26 04:24:45 -07:00
Brandon Thomas f48d2beea7 new website downloads 2026-09-26 04:24:15 -07:00
Brandon Thomas dc72bbd5e6 Merge pull request #31 from storytold/bt-sso-otp
desktop app login bridge
2026-09-26 02:43:00 -07:00
Brandon Thomas b6a184bcb0 enum variants 2026-09-26 02:36:24 -07:00
Brandon Thomas d0e1d113fe break out queries 2026-09-26 02:13:23 -07:00
Brandon Thomas 1facb312dc tests 2026-09-26 01:57:16 -07:00
Brandon Thomas f2c8a0390f final fixes 2026-09-26 00:46:19 -07:00
Brandon Thomas 2e83d39d2b login challenge on website works 2026-09-26 00:22:45 -07:00
Brandon Thomas 4f1c9134ee WIP SSO OTP bridge 2026-09-25 23:23:00 -07:00
Brandon Thomas f0a877c988 Merge pull request #30 from storytold/bt-fix-thumbs
thumbnail generation speedup / concurrency
2026-09-20 10:45:32 -04:00
BFlat f77f09018a Landing: wordmark thickness update (#29) 2026-09-20 20:31:18 +07:00
164 changed files with 8149 additions and 745 deletions

No files matched your search

@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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 "
}
@@ -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 "
}
@@ -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 "
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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 "
}
@@ -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 "
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+48 -3
View File
@@ -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
View File
@@ -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;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE user_sessions
DROP COLUMN maybe_creation_type;
@@ -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;
+6
View File
@@ -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
View File
@@ -1 +0,0 @@
AGENTS.md
+6
View File
@@ -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),
+323
View File
@@ -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;
@@ -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 -8
View File
@@ -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.
@@ -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
}
@@ -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;
@@ -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());
}
}
}
@@ -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;
@@ -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(())
}
@@ -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)
}
@@ -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())
}
@@ -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(())
}
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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>,
}
@@ -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,
}
@@ -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))
}
@@ -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)
@@ -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;
@@ -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?;
@@ -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;
@@ -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
}
@@ -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
}
@@ -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
"#,
@@ -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;
@@ -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());
}
}
}
@@ -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;
@@ -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,
@@ -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());
}
@@ -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;
@@ -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());
}
}
@@ -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})))
}
@@ -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,
}
}))
}
@@ -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;
@@ -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;
@@ -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 =
@@ -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;
@@ -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);
}
@@ -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;
+101
View File
@@ -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
```
+13 -6
View File
@@ -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