Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46fd907182 | ||
|
|
5bdc19f573 | ||
|
|
6b34f23b43 | ||
|
|
ddf151794e | ||
|
|
7a88118349 | ||
|
|
e07bf35cd0 | ||
|
|
a1147a98d2 | ||
|
|
f67fb7db31 | ||
|
|
7d4e89ced9 | ||
|
|
b9ee1bd225 | ||
|
|
68886b62fe | ||
|
|
293a41c7d4 | ||
|
|
35a859529f | ||
|
|
3a93e358cd | ||
|
|
1cb0b32898 | ||
|
|
c50bb78c32 | ||
|
|
55ab800542 | ||
|
|
2c93b4cf42 | ||
|
|
9169e4d662 | ||
|
|
4c7af51851 | ||
|
|
eadfb4f821 | ||
|
|
0c163d6c19 | ||
|
|
26484685ee | ||
|
|
5bceb33801 | ||
|
|
c6e2c4296a | ||
|
|
8551d58def | ||
|
|
807180bf22 | ||
|
|
f48d2beea7 | ||
|
|
dc72bbd5e6 | ||
|
|
b6a184bcb0 | ||
|
|
d0e1d113fe | ||
|
|
1facb312dc | ||
|
|
f2c8a0390f | ||
|
|
2e83d39d2b | ||
|
|
4f1c9134ee | ||
|
|
f0a877c988 | ||
|
|
1b2d2f49e5 | ||
|
|
3e6d759fe2 | ||
|
|
f77f09018a | ||
|
|
fc9d727ffc | ||
|
|
e2da58b3e5 | ||
|
|
681f32b230 | ||
|
|
edfa80dde3 |
No files matched your search
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status: UserLoginChallengeStatus",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type: UserLoginChallengeFailureType",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74",
|
||||
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()\n"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "expired",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"max_size": 1,
|
||||
"type": "LongLong"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "1c618e9816d6f3f7f217770f988a132d9521693da6474e945628d1b3efbc4b80",
|
||||
"query": "\nSELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
}
|
||||
},
|
||||
"hash": "2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES (?, ?, ?, ?, ?, ?)\n "
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "session_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "459898b1b42a51097dcbc0f182bd0badce642b11bd33a7e1973a6cc25538ffbf",
|
||||
"query": "\nSELECT\n token as session_token,\n user_token\nFROM user_sessions\nWHERE token = ?\nAND deleted_at IS NULL\nAND expires_at > NOW()\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"hash": "705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = ?\n AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL\n AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,\n maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()\nWHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL\n"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "8bfbb8bef3bec235f37c5f4f5c2f3a8b0833241a63da122b8abd97cd9b71208f",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()\n AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE\n AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_address",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_gravatar_hash",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed_by_google",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_is_synthetic",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_without_password",
|
||||
"ordinal": 8,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username_is_not_customized",
|
||||
"ordinal": 9,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_stripe_customer_id",
|
||||
"ordinal": 10,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_loyalty_program_key",
|
||||
"ordinal": 11,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disable_gravatar",
|
||||
"ordinal": 12,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_audio_preference",
|
||||
"ordinal": 13,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_video_preference",
|
||||
"ordinal": 14,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 15,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 16,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_role_slug",
|
||||
"ordinal": 17,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_banned",
|
||||
"ordinal": 18,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_access_studio",
|
||||
"ordinal": 19,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_feature_flags",
|
||||
"ordinal": 20,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_tts",
|
||||
"ordinal": 21,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_w2l",
|
||||
"ordinal": 22,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_results",
|
||||
"ordinal": 23,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_results",
|
||||
"ordinal": 24,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_account",
|
||||
"ordinal": 25,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_tts_models",
|
||||
"ordinal": 26,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_w2l_templates",
|
||||
"ordinal": 27,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_models",
|
||||
"ordinal": 28,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_templates",
|
||||
"ordinal": 29,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_approve_w2l_templates",
|
||||
"ordinal": 30,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_profiles",
|
||||
"ordinal": 31,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_tts_models",
|
||||
"ordinal": 32,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_w2l_templates",
|
||||
"ordinal": 33,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_models",
|
||||
"ordinal": 34,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_results",
|
||||
"ordinal": 35,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_templates",
|
||||
"ordinal": 36,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_results",
|
||||
"ordinal": 37,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_ban_users",
|
||||
"ordinal": 38,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_users",
|
||||
"ordinal": 39,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "934aad794dcb0057e39c471e7b25e5e191a395a2bd16553cb58a483b91c68437",
|
||||
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND user_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status: UserLoginChallengeStatus",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type: UserLoginChallengeFailureType",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6",
|
||||
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
}
|
||||
},
|
||||
"hash": "cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80",
|
||||
"query": "\nINSERT INTO user_login_challenges\n (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)\nVALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000\n"
|
||||
}
|
||||
+27
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n id,\n token as `token: MediaFileToken`,\n created_at as `created_at: DateTime<Utc>`,\n maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n public_bucket_directory_hash,\n maybe_public_bucket_prefix,\n maybe_public_bucket_extension\nFROM media_files\nWHERE\n id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)\n AND id < ?\n AND media_class = ?\n AND maybe_thumbnail_version IS NULL\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nORDER BY id DESC\nLIMIT ?\n ",
|
||||
"query": "\nSELECT\n media.id,\n media.token as `token: MediaFileToken`,\n media.created_at as `created_at: DateTime<Utc>`,\n NOW(6) as `database_read_at!: DateTime<Utc>`,\n jobs.successfully_completed_at as `maybe_generation_completed_at?: DateTime<Utc>`,\n media.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n media.public_bucket_directory_hash,\n media.maybe_public_bucket_prefix,\n media.maybe_public_bucket_extension\nFROM media_files AS media\nLEFT JOIN generic_inference_jobs AS jobs ON jobs.token = media.maybe_source_job_token\nWHERE\n media.id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)\n AND media.id < ?\n AND media.media_class = ?\n AND media.maybe_thumbnail_version IS NULL\n AND media.user_deleted_at IS NULL\n AND media.mod_deleted_at IS NULL\nORDER BY media.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -35,6 +35,26 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "database_read_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 26
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_generation_completed_at?: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_thumbnail_version: u8",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
@@ -44,7 +64,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 6,
|
||||
"name": "public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -54,7 +74,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 7,
|
||||
"name": "maybe_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -64,7 +84,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 8,
|
||||
"name": "maybe_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -81,11 +101,13 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "daed2dcd03b3c9fbdb9057b743df078f43e2af87a2c31f418234f98da5ff979f"
|
||||
"hash": "d14ad3a64d261e64fa75f97c93ca74af97fad8591d6ebe9887aeabc43bed0982"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
}
|
||||
},
|
||||
"hash": "da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,\n maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,\n maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())\nWHERE id = ? AND status = ? AND expires_at > NOW()\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c",
|
||||
"query": "\nINSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)\nSELECT ?, token, ?, ?, NOW() + INTERVAL 1 YEAR FROM users\nWHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL\n"
|
||||
}
|
||||
@@ -32,12 +32,46 @@ 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)
|
||||
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (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 +97,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
+13
@@ -313,6 +313,7 @@ dependencies = [
|
||||
"actix-http",
|
||||
"actix-rt",
|
||||
"actix-web",
|
||||
"domains",
|
||||
"log",
|
||||
"server_environment",
|
||||
"speculoos",
|
||||
@@ -2688,6 +2689,13 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "domains"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
@@ -8715,6 +8723,7 @@ dependencies = [
|
||||
"datetimes",
|
||||
"derive_more 0.99.17",
|
||||
"diesel",
|
||||
"domains",
|
||||
"easyenv",
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
@@ -8764,6 +8773,7 @@ dependencies = [
|
||||
"redis_common",
|
||||
"redis_schema",
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"resend-rs",
|
||||
"reusable_types",
|
||||
"rootly_client",
|
||||
@@ -8775,6 +8785,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"sha1_hash",
|
||||
"sha2 0.11.0-rc.5",
|
||||
"shared_env_var_config",
|
||||
"speculoos",
|
||||
"sqlx",
|
||||
@@ -10384,11 +10395,13 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bootstrap",
|
||||
"bucket_paths",
|
||||
"chrono",
|
||||
"cloud_storage",
|
||||
"concurrency",
|
||||
"easyenv",
|
||||
"errors",
|
||||
"ffmpeg_utils",
|
||||
"futures 0.3.32",
|
||||
"jobs_common",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
|
||||
@@ -67,6 +67,7 @@ members = [
|
||||
"crates/lib/files/zip_archives",
|
||||
"crates/lib/http_headers",
|
||||
"crates/lib/jobs_common",
|
||||
"crates/lib/domains",
|
||||
"crates/lib/jwt_light",
|
||||
"crates/lib/opaque_cursors",
|
||||
"crates/lib/jwt_signer",
|
||||
@@ -252,6 +253,7 @@ grok_api_client = { path = "crates/api_clients/grok_api_client" }
|
||||
rootly_client = { path = "crates/api_clients/rootly_client" }
|
||||
rootly_config = { path = "crates/schema/config/rootly_config" }
|
||||
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
|
||||
domains = { path = "crates/lib/domains" }
|
||||
http_headers = { path = "crates/lib/http_headers" }
|
||||
images = { path = "crates/lib/files/images" }
|
||||
jobs_common = { path = "crates/lib/jobs_common" }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_login_challenges;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
|
||||
status VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE user_sessions
|
||||
DROP COLUMN maybe_creation_type;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
ALTER TABLE user_sessions
|
||||
ADD COLUMN maybe_creation_type VARCHAR(32) DEFAULT NULL;
|
||||
@@ -24,6 +24,11 @@ These are created with the `diesel` tool, and in particular:
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- Application-owned state/status/type columns must not have schema defaults. Use
|
||||
`NOT NULL` without `DEFAULT` when required, and have application inserts explicitly
|
||||
supply the value. Document the corresponding Rust enum; application queries bind its
|
||||
variants instead of hardcoding enum strings in SQL. See
|
||||
`crates/schema/database/mysql_queries/AGENTS.md` for the query rules.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
@@ -33,6 +38,7 @@ These are created with the `diesel` tool, and in particular:
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep documentation in this directory as regular files, not symlinks. Diesel can treat a documentation symlink as a migration directory and reject the entire migration list.
|
||||
- Every `up.sql` must have a corresponding `down.sql` that cleanly reverses the migration.
|
||||
- For `CREATE TABLE` migrations, the `down.sql` is typically `DROP TABLE IF EXISTS table_name;`.
|
||||
- For `ALTER TABLE` migrations, the `down.sql` reverses the alteration (e.g. `DROP COLUMN`).
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,6 @@
|
||||
# SQL Migrations
|
||||
|
||||
Read and follow [AGENTS.md](AGENTS.md) in this directory.
|
||||
|
||||
This must remain a regular file. Diesel treats a symlink here as a migration
|
||||
directory and fails with `Invalid migration directory`.
|
||||
@@ -13,6 +13,9 @@ Update the corresponding squashed file whenever a migration changes a table's sc
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- Application-owned state/status/type columns must not have schema defaults. Required
|
||||
values stay `NOT NULL` and are supplied explicitly by application code using the
|
||||
corresponding Rust enum. Keep these definitions consistent with the applied migrations.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
|
||||
status VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -41,6 +41,12 @@ CREATE TABLE user_sessions (
|
||||
-- designed to be recoverable.
|
||||
deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
maybe_creation_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# Website-approved device login
|
||||
|
||||
Implemented across storyteller-web, the browser approval page, and the Tauri
|
||||
login dialog. Apply the two migrations before deploying the API; deploy the web
|
||||
approval route before releasing the desktop buttons. No production migrations or
|
||||
deployments were performed while developing this change.
|
||||
|
||||
Call the flow **device approval**, and each attempt a **login challenge**. It works
|
||||
for a desktop browser link or a phone scanning a QR code, regardless of how the
|
||||
approving account signed in. It creates an ordinary cookie-based web session.
|
||||
|
||||
The design borrows separate device/browser credentials, polling, explicit consent,
|
||||
and code comparison from [OAuth device authorization (RFC 8628)](https://www.rfc-editor.org/rfc/rfc8628.html#section-3.3.1).
|
||||
It is an ArtCraft session bridge, not an OAuth token endpoint implementation.
|
||||
|
||||
## Two migrations
|
||||
|
||||
1. `2026-09-25-120000-0000_create_user_login_challenges` creates the challenge and
|
||||
its terminal outcome audit record together. A separate failure table is not
|
||||
needed for one immutable outcome per attempt. Status is `NOT NULL` without a
|
||||
default; inserts explicitly bind `UserLoginChallengeStatus::Pending`.
|
||||
2. `2026-09-25-120000-0001_alter_user_sessions_add_maybe_creation_type` adds nullable
|
||||
provenance to cookie sessions. Existing rows and older writers retain NULL.
|
||||
|
||||
Both have reverse migrations and matching materialized schemas under
|
||||
`migrations_squashed/user_tables/`. Deploy the schema before new writers. Roll
|
||||
back feature code before dropping the column/table; rollback removes bridge audit
|
||||
records but does not revoke sessions already issued.
|
||||
|
||||
`UserWebSessionCreationType` values:
|
||||
|
||||
| Rust variant | Stored value | Meaning |
|
||||
|------------------|-------------------|-------------------------------------------------------------------|
|
||||
| DirectLogin | direct_login | Password or SSO login/signup, including desktop password login. |
|
||||
| DeviceApproval | device_approval | A new session minted from an approved login challenge. |
|
||||
| Impersonation | impersonation | Existing consented staff impersonation flow. |
|
||||
| None | NULL | Unknown provenance, including existing rows and older writers. |
|
||||
|
||||
`DirectLogin` describes the creation mechanism more accurately than `Web`, since
|
||||
ordinary password login is also available in the desktop app. This field does not
|
||||
replace `maybe_impersonation_user_token` or influence authentication/authorization.
|
||||
MCP and API credentials continue using `mcp_sessions` and `api_keys` respectively.
|
||||
Existing password/SSO and impersonation session writers now record provenance.
|
||||
There is no historical backfill or default that would invent it.
|
||||
|
||||
## Credentials and confirmation
|
||||
|
||||
Generate two independent 32-byte CSPRNG values per challenge. Encode each as
|
||||
unpadded base64url (43 ASCII characters). Store only the SHA-256 digest of that
|
||||
canonical ASCII representation in the two uniquely indexed `BINARY(32)` columns.
|
||||
Reject noncanonical input instead of accepting multiple representations. These
|
||||
are random tokens, not passwords, so a fast cryptographic hash is appropriate.
|
||||
|
||||
| Value | Who receives it | What it permits |
|
||||
|---------------------|-------------------------------|--------------------------------------------------------|
|
||||
| token | Internal logs/support | Identifying a row; no authentication. |
|
||||
| approval token | Desktop, browser link, QR | Review and authenticated approval/decline only. |
|
||||
| device token | Initiating desktop only | Polling and collecting the newly authorized session. |
|
||||
| confirmation code | Desktop and approval screen | Visual comparison only; no lookup or authentication. |
|
||||
|
||||
Use eight uniformly random characters from `BCDFGHJKLMNPQRSTVWXZ` for the code.
|
||||
Store `WDJBMJHT`, display `WDJB-MJHT`, and ask the user to verify the match. It need
|
||||
not be unique because it is never used to locate or authorize a challenge. Manual
|
||||
short-code entry is outside this initial flow; both entry points carry the full
|
||||
approval token. The private device token must never be in the URL or QR code.
|
||||
|
||||
The website displays the requesting IP, the account being authorized, the matching
|
||||
code, and explicit **Approve desktop login** / **Decline** actions. An existing
|
||||
website session skips only the login screen, never consent. If login is needed,
|
||||
keep the pending approval context in first-party browser state and resume the
|
||||
confirmation page after password or Google SSO login. Recheck expiry after login.
|
||||
Do not send the bridge tokens to Google or another identity provider. Allow only
|
||||
the fixed first-party return route, not an arbitrary caller-supplied redirect URL.
|
||||
|
||||
The approval page is a read-only GET; link scanners and previews cannot decide
|
||||
the challenge. Decisions require a valid, non-impersonated user web session plus
|
||||
CSRF/origin protection. Never accept an API key or MCP credential as consent.
|
||||
Require the user to have initiated the request on a device in their possession;
|
||||
the visible code and IP help detect phishing but are not authentication factors.
|
||||
|
||||
The approval URL uses HTTPS and carries the browser credential in its fragment.
|
||||
The page captures and removes that fragment before referral collection or React
|
||||
providers initialize, skips analytics/referral tracking on direct entry, and sets
|
||||
a no-referrer policy. API responses send `Cache-Control: no-store` and
|
||||
`Referrer-Policy: no-referrer`; API credentials travel in JSON bodies, never query
|
||||
strings. Shared fonts and the Google sign-in provider still load. Keep request and
|
||||
response bodies out of gateway logs. Invalid credentials reveal no account or
|
||||
challenge details. The IP follows the existing trusted-gateway forwarding policy;
|
||||
the gateway must replace untrusted incoming forwarding headers.
|
||||
|
||||
## State and the 20-minute deadline
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> approved: authenticated approval
|
||||
pending --> failed: user_declined
|
||||
pending --> failed: expired
|
||||
approved --> failed: expired before redemption
|
||||
approved --> redeemed: atomically mint one session
|
||||
redeemed --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
Set `created_at = NOW()` and `expires_at = NOW() + INTERVAL 20 MINUTE` in the same
|
||||
INSERT using the database's clock. The default expiry is immediate so a writer
|
||||
that omits it fails closed. Neither approval nor polling extends this deadline.
|
||||
All eligibility checks require `expires_at > NOW()`; equality means expired.
|
||||
The create query enforces this exact lifetime; these migrations do not add
|
||||
triggers or database CHECK constraints. Keep database connections in UTC.
|
||||
|
||||
The persisted status alone is insufficient to decide validity. A pending or
|
||||
approved row whose deadline passed is already expired even before a sweeper
|
||||
marks it `failed`. Approvals and first redemptions must check the deadline again
|
||||
under the row lock. Polls are normally read-only and do not touch `updated_at`.
|
||||
|
||||
| Status | Required accompanying data |
|
||||
|------------|-------------------------------------------------------------------------------------|
|
||||
| pending | No decision, failure, redemption, or session reference. |
|
||||
| approved | Deciding user, decision IP/time; no failure, redemption, or session reference. |
|
||||
| redeemed | Approval data plus session ID, first redemption IP/time; no failure. |
|
||||
| failed | Failure type/time; no redemption or session reference; retain any prior approval. |
|
||||
|
||||
`UserLoginChallengeFailureType` initially has two values:
|
||||
|
||||
- `user_declined`: record the deciding user, browser IP in both decision/failure
|
||||
IP fields, and the decision time in both decision/failure timestamps. Keep the
|
||||
original desktop IP. Polling with the valid device token reports the decline.
|
||||
- `expired`: record `maybe_failed_at = expires_at`. Retain original desktop IP and
|
||||
any prior approval, but leave the failure IP NULL: a timeout has no actor. A
|
||||
scheduled sweep records abandoned requests even when nobody polls again.
|
||||
|
||||
Failed challenges are unusable for approval or redemption. Their digests and
|
||||
audit metadata remain for diagnosing failures; do not physically delete the row
|
||||
when a user declines. A new attempt creates a new row and fresh tokens. Closing
|
||||
the desktop dialog stops polling; unused requests expire normally.
|
||||
|
||||
Invalid tokens, failed website sign-ins, throttling, and transient infrastructure
|
||||
errors do not terminally fail a valid challenge. Record those requests in the
|
||||
existing operational/abuse logging with their IP and failure category, without
|
||||
credentials. A second decision cannot replace the first actor or outcome.
|
||||
|
||||
## Atomic redemption and response loss
|
||||
|
||||
The website's approval records consent; it never creates or receives the desktop
|
||||
cookie. The desktop's authenticated poll performs redemption after approval:
|
||||
|
||||
1. Begin an InnoDB transaction on the primary and find the challenge by the device
|
||||
token digest with `SELECT ... FOR UPDATE`.
|
||||
2. Validate the immutable deadline and state after taking the lock. Handle
|
||||
failed/pending states without creating a session. Revalidate that the approved
|
||||
user exists and is permitted to log in under the ordinary session rules.
|
||||
3. For `approved`, insert a fresh `user_sessions` row owned by the deciding user,
|
||||
with `maybe_creation_type = 'device_approval'`, no impersonation, the current
|
||||
desktop redemption IP, and the ordinary web-session lifetime.
|
||||
4. Store its numeric ID, redemption IP/time, and `status = 'redeemed'` on the
|
||||
challenge in that same transaction. Generate the signed cookie using
|
||||
`HttpUserSessionManager::create_cookie` before commit, so signing failure rolls
|
||||
back too. Commit successfully before returning any credential to the caller.
|
||||
|
||||
Both approval/decline and redemption must serialize on the same challenge row;
|
||||
conditional updates include the expected state and the unexpired deadline, and
|
||||
must check rows affected. SQL for this flow belongs in `mysql_queries`, with
|
||||
executor/transaction support, not in the web handler. All three Rust enums in this
|
||||
change are database enums; separate client-facing status/failure enums include
|
||||
an unknown-value fallback. Desktop callers fail closed on an unknown state.
|
||||
|
||||
One-time means **at most one new session per challenge**. If the first successful
|
||||
HTTP response is lost, a retry with the same device token may return the same
|
||||
session until the original 20-minute deadline. For `redeemed`, read the linked
|
||||
session and verify its owner, creation type, absence of impersonation, expiry,
|
||||
deletion state, and the user's current eligibility before reconstructing its
|
||||
cookie. Never mint a replacement or resurrect a deleted session. After the
|
||||
deadline, return expiry without a cookie; the already-issued session keeps its
|
||||
normal lifetime, and the audit outcome remains `redeemed`.
|
||||
|
||||
The unique nullable session ID index prevents two challenges from referencing one
|
||||
session and supports retries without duplicating bearer credentials in this
|
||||
table. The reference is intentionally signed because the legacy `user_sessions.id`
|
||||
is signed. Like neighboring schemas, references are indexed logical links rather
|
||||
than cascading foreign keys; removal of a session must not erase the audit row.
|
||||
If the referenced session is missing, redemption fails closed.
|
||||
|
||||
## Implemented endpoints and clients
|
||||
|
||||
All endpoints are JSON POSTs under `/v1/login_challenges`:
|
||||
|
||||
| Endpoint | Authentication | Effect |
|
||||
|------------|---------------------------------------------|--------------------------------------------------------------|
|
||||
| create | None; limited by requesting IP | Returns device token, verification URL, code, expiry. |
|
||||
| review | Approval token + web session + Origin | Returns code, requesting IP, account, state, expiry. |
|
||||
| decide | Approval token + web session + Origin | Records the first Yes/No; never creates a desktop session. |
|
||||
| poll | Private device token | Reports state or atomically redeems approved consent. |
|
||||
|
||||
The API advertises five-second polling. Per-process IP limits are ten creates and
|
||||
120 other requests per minute, shared across Actix workers, with bounded memory.
|
||||
Production gateways can additionally enforce aggregate limits across replicas.
|
||||
A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention
|
||||
is intentionally not an automatic purge policy in this change.
|
||||
|
||||
Both browser applications implement `/login/desktop`: `https://getartcraft.com`
|
||||
(including `www`) and `https://app.getartcraft.com`. They share the approval UI in
|
||||
`frontend/libs/login`. The API currently advertises the webapp URL. Local API
|
||||
requests advertise `http://localhost:4201/login/desktop`; the website also handles
|
||||
the route on port 4200. Run either frontend with `USE_LOCAL_API=1` so approval
|
||||
reaches the same local API; local origins cannot approve production challenges.
|
||||
The website signs in inline to preserve its origin-scoped pending token and
|
||||
mobile session header. The webapp preserves the return destination through login,
|
||||
signup, and password reset, bypassing billing redirects for desktop consent.
|
||||
Tokens stay in tab-scoped session storage, are removed from URL fragments before
|
||||
tracking, and are cleared on terminal results. Returning from authentication
|
||||
always requires explicit consent. The code stays visible for comparison; a single
|
||||
Approve or Decline click records the choice, without an extra checkbox.
|
||||
The desktop displays the QR code, code comparison, countdown, and status. Opening
|
||||
the website invokes the native system browser. Network errors back off up to 30
|
||||
seconds; closing the dialog stops polling and cancels the native handle. A response
|
||||
arriving after cancellation cannot install credentials.
|
||||
|
||||
Successful polling returns the signed value and the normal `Set-Cookie` header.
|
||||
Tauri commands use the Rust `artcraft_client` bindings for creation, polling, and
|
||||
ordinary session verification, all against `AppEnvConfigs.storyteller_host`. Rust
|
||||
keeps the private device token and signed session out of IPC/JavaScript; the UI
|
||||
holds only a local handle and public display data. After verifying the downstream
|
||||
session, Rust installs and persists the cookie in the native HTTP jar and updates
|
||||
the credential manager. No browser session is copied into the desktop.
|
||||
The desktop login/signup modal also uses Rust commands for its initial session
|
||||
check and password forms. Website/QR buttons appear only in login mode. Unknown
|
||||
optional feature flags in the session response are ignored by older desktops;
|
||||
malformed authentication data still fails verification. A stale initial session
|
||||
check cannot reset a pending or completed login.
|
||||
|
||||
## Integration tests
|
||||
|
||||
The backend harness uses the fixed local Unix socket `/tmp/mysql.sock` and local
|
||||
root account. It never reads `DATABASE_URL`, any environment database URL, DNS,
|
||||
or a TCP endpoint (including localhost SSH forwarding). Each test creates a new
|
||||
randomly named `artcraft_test_bridge_*` database, replays all migrations, and drops
|
||||
only its own database on success. Existing `storyteller` and `artcraft_test` data
|
||||
are not used. A failed test leaves its isolated database for diagnosis. The local
|
||||
MySQL instance must be running; there is no fallback to a remote database.
|
||||
|
||||
The HTTP endpoint suite is
|
||||
`crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs`.
|
||||
It starts the real challenge handlers and `/v1/session` handler on an ephemeral
|
||||
`127.0.0.1` port. Separate browser and desktop HTTP clients maintain their own
|
||||
cookie jars; requests cannot follow redirects or use system proxies. Fixtures in
|
||||
`mysql_testing::fixtures::login_challenges` create passwordless users, Google email
|
||||
confirmation, feature flags (including `use_qt`), and signed browser sessions.
|
||||
The desktop cookie comes only from the real redemption response and is then sent
|
||||
to the real session endpoint. HTTP responses and SQL queries are not mocked.
|
||||
|
||||
From the services repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
|
||||
cd frontend
|
||||
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
|
||||
npm exec vitest -- run --config apps/artcraft-website/vite.config.ts src/pages/login/desktop-login.spec.tsx
|
||||
```
|
||||
|
||||
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
|
||||
real SQL against local MySQL. Do not enable `skip_database_tests` for this run.
|
||||
|
||||
To run only the four HTTP endpoint tests with database fixtures:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges::endpoint_integration_tests
|
||||
```
|
||||
|
||||
From the desktop repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
|
||||
cd frontend
|
||||
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
|
||||
```
|
||||
|
||||
Coverage:
|
||||
|
||||
- Four HTTP endpoint integration tests with database fixtures: the complete
|
||||
create/review/approve/redeem/session flow, independent browser/desktop cookies,
|
||||
passwordless onboarding and `use_qt` serialization, concurrent redemption,
|
||||
lost-response recovery with a fresh client, no sessions before redemption,
|
||||
audit ownership/IPs/timestamps, decline, pending/approved expiry, downstream
|
||||
session revocation/expiry/bans, and immutable approval ownership across accounts.
|
||||
- Seven additional backend handler integration tests: both production browser origins, passwordless account approval, zero sessions
|
||||
before redemption, rejection/audit IPs, token separation, CSRF, MCP rejection,
|
||||
invalid approving sessions, concurrent decisions/redemptions, response-loss
|
||||
retries, abandoned expiry, expiry during lock contention, revocation, bans,
|
||||
signed-cookie authentication through the production session guard, and an issued
|
||||
session remaining valid after the challenge expires.
|
||||
- Twenty-five browser tests: API/consent interaction, code/IP display, decline,
|
||||
expiry, terminal/unknown states, lost-response retries, safe return URLs,
|
||||
website inline Google/password sign-in, and webapp login/signup/password-reset
|
||||
continuation without subscription gating or bridge credentials in SSO requests.
|
||||
- Thirteen desktop UI tests: IPC-only transport (JavaScript HTTP is forbidden),
|
||||
native browser invocation, QR/code values, polling, verified user results,
|
||||
decline, timeout, network backoff, cancellation, host/status diagnostics,
|
||||
login-only button placement, native password forms, and stale session checks.
|
||||
- Thirteen native tests: real API bindings against ephemeral loopback HTTP servers,
|
||||
downstream verification before cookie installation, persistence, retries,
|
||||
rejection, timeout, cancellation during HTTP, unknown states, no redirects,
|
||||
redacted errors, local/production origin isolation, visitor-only telemetry,
|
||||
forward-compatible feature flags, malformed session data, and native password
|
||||
login/signup/session recheck. Desktop tests do not connect to MySQL.
|
||||
|
||||
Frontend tests replace external Google/native boundaries; they do not
|
||||
perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests
|
||||
use real MySQL and the production cookie signer/session guard with Redis disabled;
|
||||
the HTTP suite also exercises the real session endpoint and its serialized body.
|
||||
These endpoint tests do not drive a live Google OAuth exchange or the Tauri GUI.
|
||||
Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired
|
||||
sessions; bridge consent/redemption always checks MySQL directly.
|
||||
|
||||
Validation also covered both migration directions, nullable compatibility with
|
||||
legacy session writers, uniqueness constraints, fail-closed expiry defaults, and
|
||||
matching materialized schemas. The focused web TypeScript check and the production
|
||||
builds of both browser apps (including 53 dependency tasks) passed. Broad workspace TypeScript
|
||||
checks have existing errors. The desktop Nx build is blocked by the existing
|
||||
`api -> tauri-api -> api` cycle; its direct Vite login bundle builds but declaration
|
||||
checks report unavailable dependency outputs. A source-only desktop check reports
|
||||
four existing `model-list` imports of `src/index.js`, with no bridge-file errors.
|
||||
The full native Rust test target compiled successfully.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::responses::cover_image_links::CoverImageLinks;
|
||||
use crate::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
|
||||
|
||||
// User-media list endpoints retain the deprecated bucket fields for compatibility.
|
||||
/// Everything we need to create a cover image.
|
||||
/// Cover images are small descriptive images that can be set for any media file.
|
||||
/// If a cover image is set, this is the path to the asset.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct LegacyMediaFileCoverImageDetails {
|
||||
/// (DEPRECATED) URL path to the media file
|
||||
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
|
||||
pub maybe_cover_image_public_bucket_path: Option<String>,
|
||||
|
||||
/// (DEPRECATED) Full URL to the media file
|
||||
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
|
||||
pub maybe_cover_image_public_bucket_url: Option<Url>,
|
||||
|
||||
/// Links to the cover image (CDN direct link, thumbnail template)
|
||||
/// If a cover image is set, this is the path to the asset.
|
||||
/// If a cover image is not set, use the information in `default_cover` instead.
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub maybe_links: Option<CoverImageLinks>,
|
||||
|
||||
/// For items without a cover image, we can use one of our own.
|
||||
pub default_cover: MediaFileDefaultCover,
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
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 tokens::tokens::model_weights::ModelWeightToken;
|
||||
|
||||
/// Fields useful for enriching media file listings
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileOriginDetails {
|
||||
/// Where the file came from (broadly)
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
/// Where the file came from (specifically, a product area, eg. "face_animator")
|
||||
pub product_category: MediaFileOriginProductCategory,
|
||||
|
||||
/// If the file was produced by a model, the details.
|
||||
pub maybe_model: Option<MediaFileModelDetails>,
|
||||
}
|
||||
|
||||
/// Information about the model weights
|
||||
/// https://serde.rs/enum-representations.html#untagged
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum MediaFileModelDetails {
|
||||
// Try the specific shape first so deserialization retains token and title.
|
||||
/// A model weight is typically a user-submitted model.
|
||||
/// We store lots of these.
|
||||
ModelWeight {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
/// The model token
|
||||
token: ModelWeightToken,
|
||||
/// The model title (typically only populated for `model_weights` models, not legacy tables such as `tts_models`.)
|
||||
title: String,
|
||||
},
|
||||
/// A system model is one that we maintain and update,
|
||||
/// eg. SadTalker, Wav2Lip, MocapNet, etc.
|
||||
SystemModel {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod cover_image_links;
|
||||
pub mod job_details;
|
||||
pub mod legacy_media_file_cover_image_details;
|
||||
pub mod media_file_cover_image_details;
|
||||
pub mod media_file_origin_details;
|
||||
pub mod media_links;
|
||||
pub mod pagination_cursors;
|
||||
pub mod simple_entity_stats;
|
||||
pub mod simple_generic_json_success;
|
||||
pub mod user_details_light;
|
||||
pub mod user_details_light;
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// TODO(bt,2025-08-20): Replace the storyteller-web version of this.
|
||||
|
||||
/// Simple stats that can be attached to any entity
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct SimpleEntityStats {
|
||||
/// Number of positive ratings (or "likes") for this item
|
||||
pub positive_rating_count: u32,
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
|
||||
pub const LIST_MEDIA_FILES_FOR_USER_V2_URL_PATH: &str = "/v1/media_files/list_v2/user/{username}";
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2PathInfo {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, 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, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2SuccessResponse {
|
||||
pub success: bool,
|
||||
pub results: Vec<MediaFileForUserListItem>,
|
||||
pub pagination: UserMediaFileV2Pagination,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserMediaFileV2Pagination {
|
||||
pub current: usize,
|
||||
pub has_more: bool,
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pub mod by_type;
|
||||
pub mod list_media_files_for_user_v2;
|
||||
pub mod user_media_file_list_item;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
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 enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use crate::common::responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::common::responses::media_links::MediaLinks;
|
||||
use crate::common::responses::simple_entity_stats::SimpleEntityStats;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileForUserListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
/// The coarse-grained class of media file: image, video, etc.
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
/// Type of media will dictate which fields are populated and what
|
||||
/// the frontend should display (eg. video player vs audio player).
|
||||
/// This is closer in meaning to a "mime type".
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
/// If this is an engine/3D asset, this is the broad category (scene,
|
||||
/// animation, etc.) of that object.
|
||||
/// This can also be used for filtering in list/batch endpoints.
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
|
||||
/// If this is an engine/3D asset for an animation or a rig that can
|
||||
/// be animated with either (or both) skeletal or blend shape animations,
|
||||
/// this describes the animation regime used or supported.
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
/// Details where the media file came from.
|
||||
pub origin: MediaFileOriginDetails,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
/// (DEPRECATED) URL path to the media file
|
||||
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_path: String,
|
||||
|
||||
/// (DEPRECATED) Full URL to the media file
|
||||
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_url: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// The foreign key to the prompt used to generate the media, if applicable.
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// Information about the cover image. Many media files do not require a cover image,
|
||||
/// e.g. image files, video files with thumbnails, audio files, etc.
|
||||
/// 3D files require them.
|
||||
pub cover_image: LegacyMediaFileCoverImageDetails,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
/// The file was uploaded by the user.
|
||||
/// This does not include files generated on the client side, like studio renders.
|
||||
pub is_user_upload: bool,
|
||||
|
||||
/// The file was created by the system.
|
||||
/// This includes files generated on the client side, like studio renders.
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
/// The name or title of the media file (optional)
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
|
||||
/// For Comfy / Video Style Transfer jobs, this might include
|
||||
/// the name of the selected style.
|
||||
pub maybe_style_name: Option<StyleTransferName>,
|
||||
|
||||
/// Duration for audio and video files, if available.
|
||||
/// Measured in milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
/// Statistics about the media file
|
||||
pub stats: SimpleEntityStats,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeRequest {}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct DecideLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
pub approve: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct PollLoginChallengeRequest {
|
||||
pub device_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub device_token: String,
|
||||
pub verification_url: String,
|
||||
pub confirmation_code: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub poll_interval_seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
pub confirmation_code: String,
|
||||
pub requesting_ip: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
/// Present only to the device after the redemption transaction commits.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_signed_session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeState {
|
||||
Pending,
|
||||
Approved,
|
||||
Redeemed,
|
||||
Failed,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeFailure {
|
||||
UserDeclined,
|
||||
Expired,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod change_password;
|
||||
pub mod edit_email;
|
||||
pub mod edit_username;
|
||||
pub mod login;
|
||||
pub mod session_info;
|
||||
pub mod login_challenges;
|
||||
pub mod session_info;
|
||||
+14
-11
@@ -8,6 +8,7 @@ use enums::common::view_as::ViewAs;
|
||||
use errors::{anyhow, AnyhowResult};
|
||||
use mysql_queries::queries::media_files::delete::delete_media_file::delete_media_file_as_mod;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
|
||||
|
||||
use crate::cli_args::Args;
|
||||
@@ -42,23 +43,25 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
|
||||
info!("Querying page {page_index} of files...");
|
||||
|
||||
let media_files = list_media_files_for_user(ListMediaFileForUserArgs {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
page_size: 100,
|
||||
page_index,
|
||||
filters: UserMediaFileFilters {
|
||||
username,
|
||||
maybe_filter_media_types: None,
|
||||
maybe_filter_media_classes: None,
|
||||
maybe_filter_engine_categories: None,
|
||||
include_user_uploads: true,
|
||||
view_as: ViewAs::Moderator,
|
||||
},
|
||||
limit: 100,
|
||||
offset: page_index * 100,
|
||||
sort_ascending: false,
|
||||
view_as: ViewAs::Moderator,
|
||||
mysql_pool: &mysql,
|
||||
mysql_executor: mysql,
|
||||
}).await?;
|
||||
|
||||
if media_files.records.is_empty() {
|
||||
if media_files.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for file in media_files.records {
|
||||
for file in media_files {
|
||||
info!("Deleting file: {:?}", &file.token);
|
||||
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ normal = ["workspace-build-acceleration"]
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
domains.workspace = true
|
||||
server_environment.workspace = true
|
||||
|
||||
# External
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Regression tests pinning every exact origin the CORS config allowed before
|
||||
//! first-party domains moved to the `domains` crate (commit a1147a98d2).
|
||||
//!
|
||||
//! The origins are string literals on purpose: they check the `domains`
|
||||
//! constants against the real values instead of against themselves. Each row
|
||||
//! is checked against its module in isolation (so a module can't pass because
|
||||
//! another module allows the same origin) and against the full config.
|
||||
//!
|
||||
//! The table was generated from the `allowed_origin("...")` calls at
|
||||
//! a1147a98d2. When you add or remove an origin, update this table too.
|
||||
|
||||
use actix_cors::Cors;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::configs::artcraft_2d::add_artcraft_2d;
|
||||
use crate::configs::artcraft_3d::add_artcraft_3d;
|
||||
use crate::configs::artcraft_admin_dashboard::add_artcraft_admin_dashboard;
|
||||
use crate::configs::artcraft_desktop::add_artcraft_desktop;
|
||||
use crate::configs::artcraft_webapp::add_artcraft_webapp;
|
||||
use crate::configs::artcraft_website::add_artcraft_website;
|
||||
use crate::configs::fakeyou::add_fakeyou;
|
||||
use crate::configs::fakeyou::add_fakeyou_dev_proxy;
|
||||
use crate::configs::legacy::add_legacy_storyteller_stream;
|
||||
use crate::configs::legacy::add_legacy_trumped;
|
||||
use crate::configs::legacy::add_legacy_vocodes;
|
||||
use crate::configs::legacy::add_power_stream;
|
||||
use crate::configs::realseedance::add_realseedance;
|
||||
use crate::configs::storyteller::add_storyteller;
|
||||
use crate::configs::storyteller::add_storyteller_dev_proxy;
|
||||
use crate::configs::storyteller_board::add_storyteller_board;
|
||||
use crate::configs::storyteller_render::add_storyteller_render;
|
||||
use crate::configs::storyteller_studio::add_storyteller_studio;
|
||||
use crate::configs::tauri::add_tauri;
|
||||
use crate::cors::build_cors_config;
|
||||
use crate::testing::{assert_exact_https_origin, is_origin_allowed, isolated_cors};
|
||||
|
||||
use Env::{Both, DevelopmentOnly, ProductionOnly};
|
||||
|
||||
type AddOrigins = fn(Cors, bool) -> Cors;
|
||||
|
||||
/// Which environments an origin is configured for.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
enum Env {
|
||||
Both,
|
||||
ProductionOnly,
|
||||
DevelopmentOnly,
|
||||
}
|
||||
|
||||
const ALLOWLIST: &[(AddOrigins, Env, &str)] = &[
|
||||
// add_artcraft_2d
|
||||
(add_artcraft_2d, Both, "https://2d.storyteller.ai"),
|
||||
(add_artcraft_2d, Both, "https://2d.getartcraft.com"),
|
||||
(add_artcraft_2d, Both, "http://localhost:5741"),
|
||||
(add_artcraft_2d, Both, "http://localhost:5742"),
|
||||
(add_artcraft_2d, Both, "http://localhost:5743"),
|
||||
|
||||
// add_artcraft_3d
|
||||
(add_artcraft_3d, Both, "https://3d.storyteller.ai"),
|
||||
(add_artcraft_3d, Both, "https://3d.getartcraft.com"),
|
||||
(add_artcraft_3d, Both, "http://localhost:5173"),
|
||||
(add_artcraft_3d, Both, "http://localhost:5174"),
|
||||
(add_artcraft_3d, Both, "http://localhost:5175"),
|
||||
(add_artcraft_3d, Both, "https://macaroni-1.tailce84f.ts.net"),
|
||||
(add_artcraft_3d, Both, "https://halide.tailce84f.ts.net"),
|
||||
(add_artcraft_3d, Both, "https://brandons-macbook-pro.taild62114.ts.net"),
|
||||
|
||||
// add_artcraft_admin_dashboard
|
||||
(add_artcraft_admin_dashboard, Both, "https://artcraft-dashboard.netlify.app"),
|
||||
|
||||
// add_artcraft_desktop
|
||||
(add_artcraft_desktop, Both, "https://desktop.getartcraft.com"),
|
||||
|
||||
// add_artcraft_webapp
|
||||
(add_artcraft_webapp, Both, "https://app.getartcraft.com"),
|
||||
|
||||
// add_artcraft_website
|
||||
(add_artcraft_website, Both, "https://getartcraft.com"),
|
||||
(add_artcraft_website, Both, "https://www.getartcraft.com"),
|
||||
(add_artcraft_website, Both, "https://artcraft.ai"),
|
||||
(add_artcraft_website, Both, "https://www.artcraft.ai"),
|
||||
(add_artcraft_website, Both, "http://localhost:4200"),
|
||||
(add_artcraft_website, Both, "http://localhost:4201"),
|
||||
(add_artcraft_website, Both, "http://localhost:4202"),
|
||||
|
||||
// add_fakeyou
|
||||
(add_fakeyou, Both, "https://engine.fakeyou.com"),
|
||||
(add_fakeyou, ProductionOnly, "https://api.fakeyou.com"),
|
||||
(add_fakeyou, ProductionOnly, "https://fakeyou.com"),
|
||||
(add_fakeyou, ProductionOnly, "https://staging.fakeyou.com"),
|
||||
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com"),
|
||||
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com:7000"),
|
||||
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com:7001"),
|
||||
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com"),
|
||||
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com:7000"),
|
||||
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com:7001"),
|
||||
|
||||
// add_fakeyou_dev_proxy
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:5173"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7000"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7001"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7002"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:5173"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7000"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7001"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7002"),
|
||||
|
||||
// add_legacy_storyteller_stream
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://api.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://ws.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://api.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://ws.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://staging.obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://staging.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://staging.obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://staging.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://create.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://obs.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://ws.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://create.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://obs.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://ws.storyteller.ai"),
|
||||
|
||||
// add_legacy_trumped
|
||||
(add_legacy_trumped, ProductionOnly, "https://trumped.com"),
|
||||
(add_legacy_trumped, DevelopmentOnly, "http://dev.trumped.com"),
|
||||
(add_legacy_trumped, DevelopmentOnly, "https://dev.trumped.com"),
|
||||
|
||||
// add_legacy_vocodes
|
||||
(add_legacy_vocodes, ProductionOnly, "https://api.vo.codes"),
|
||||
(add_legacy_vocodes, ProductionOnly, "https://vo.codes"),
|
||||
(add_legacy_vocodes, ProductionOnly, "https://vocodes.com"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "http://dev.api.vo.codes"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "http://dev.vo.codes"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "https://dev.api.vo.codes"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "https://dev.vo.codes"),
|
||||
|
||||
// add_power_stream
|
||||
(add_power_stream, ProductionOnly, "https://dash.power.stream"),
|
||||
(add_power_stream, ProductionOnly, "https://power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "http://dev.dash.power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "http://dev.power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "https://dev.dash.power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "https://dev.power.stream"),
|
||||
|
||||
// add_realseedance
|
||||
(add_realseedance, Both, "https://realseedance.com"),
|
||||
(add_realseedance, Both, "https://www.realseedance.com"),
|
||||
|
||||
// add_storyteller
|
||||
(add_storyteller, ProductionOnly, "https://engine.storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://studio.storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://api.storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://staging.storyteller.ai"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:5173"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7000"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7001"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7002"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:5173"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7000"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7001"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7002"),
|
||||
|
||||
// add_storyteller_dev_proxy
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:5173"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7000"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7001"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7002"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:5173"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7000"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7001"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7002"),
|
||||
|
||||
// add_storyteller_board
|
||||
(add_storyteller_board, Both, "https://memeboard.ai"),
|
||||
(add_storyteller_board, Both, "https://dingboard.ai"),
|
||||
(add_storyteller_board, Both, "https://board.storyteller.ai"),
|
||||
(add_storyteller_board, Both, "http://localhost:5173/"),
|
||||
|
||||
// add_storyteller_render
|
||||
(add_storyteller_render, Both, "https://render.storyteller.ai"),
|
||||
|
||||
// add_storyteller_studio
|
||||
(add_storyteller_studio, Both, "https://studio.storyteller.ai"),
|
||||
(add_storyteller_studio, Both, "https://studio-staging.studio.storyteller.ai"),
|
||||
(add_storyteller_studio, Both, "https://studio-testing.studio.storyteller.ai"),
|
||||
(add_storyteller_studio, Both, "http://localhost:5173"),
|
||||
(add_storyteller_studio, Both, "https://animate.storyteller.ai"),
|
||||
|
||||
// add_tauri
|
||||
(add_tauri, Both, "http://tauri.localhost"),
|
||||
(add_tauri, Both, "tauri://localhost"),
|
||||
];
|
||||
|
||||
/// The origins whose `allowed_origin(...)` literal became a `domains` constant.
|
||||
const MIGRATED_ORIGINS: &[&str] = &[
|
||||
"https://getartcraft.com",
|
||||
"https://www.getartcraft.com",
|
||||
"https://app.getartcraft.com",
|
||||
"https://desktop.getartcraft.com",
|
||||
"https://2d.getartcraft.com",
|
||||
"https://3d.getartcraft.com",
|
||||
"https://artcraft.ai",
|
||||
"https://www.artcraft.ai",
|
||||
"https://fakeyou.com",
|
||||
"https://api.fakeyou.com",
|
||||
"https://engine.fakeyou.com",
|
||||
"https://staging.fakeyou.com",
|
||||
"https://storyteller.ai",
|
||||
"https://api.storyteller.ai",
|
||||
"https://engine.storyteller.ai",
|
||||
"https://staging.storyteller.ai",
|
||||
"https://studio.storyteller.ai",
|
||||
"https://studio-staging.studio.storyteller.ai",
|
||||
"https://studio-testing.studio.storyteller.ai",
|
||||
"https://animate.storyteller.ai",
|
||||
"https://board.storyteller.ai",
|
||||
"https://render.storyteller.ai",
|
||||
"https://2d.storyteller.ai",
|
||||
"https://3d.storyteller.ai",
|
||||
];
|
||||
|
||||
mod isolated_module_tests {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn each_module_allows_its_origins_in_production() {
|
||||
let failures = allowed_failures(true, |env| env != DevelopmentOnly, false).await;
|
||||
assert!(failures.is_empty(), "production origins not allowed by their module: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn each_module_allows_its_origins_in_development() {
|
||||
let failures = allowed_failures(false, |env| env != ProductionOnly, false).await;
|
||||
assert!(failures.is_empty(), "development origins not allowed by their module: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn development_only_origins_are_rejected_by_their_module_in_production() {
|
||||
let failures = allowed_failures(true, |env| env == DevelopmentOnly, true).await;
|
||||
assert!(failures.is_empty(), "development-only origins allowed in production: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_only_origins_are_rejected_by_their_module_in_development() {
|
||||
let failures = allowed_failures(false, |env| env == ProductionOnly, true).await;
|
||||
assert!(failures.is_empty(), "production-only origins allowed in development: {:#?}", failures);
|
||||
}
|
||||
|
||||
/// Checks each matching row against its own module's isolated config and
|
||||
/// returns the origins that were allowed (`expect_rejected`) or not.
|
||||
async fn allowed_failures(
|
||||
is_production: bool,
|
||||
select: impl Fn(Env) -> bool,
|
||||
expect_rejected: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let mut failures = Vec::new();
|
||||
for &(add_origins, env, origin) in ALLOWLIST {
|
||||
if !select(env) {
|
||||
continue;
|
||||
}
|
||||
let cors = isolated_cors(add_origins, is_production);
|
||||
if is_origin_allowed(&cors, origin).await == expect_rejected {
|
||||
failures.push(origin);
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
}
|
||||
|
||||
mod full_config_tests {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_config_allows_every_production_origin() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
let failures = full_config_failures(&cors, |env| env != DevelopmentOnly, false).await;
|
||||
assert!(failures.is_empty(), "production origins not allowed: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn development_config_allows_every_development_origin() {
|
||||
let cors = build_cors_config(ServerEnvironment::Development);
|
||||
let failures = full_config_failures(&cors, |env| env != ProductionOnly, false).await;
|
||||
assert!(failures.is_empty(), "development origins not allowed: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_config_rejects_development_only_origins() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
let failures = full_config_failures(&cors, |env| env == DevelopmentOnly, true).await;
|
||||
assert!(failures.is_empty(), "development-only origins allowed in production: {:#?}", failures);
|
||||
}
|
||||
|
||||
/// Every origin that moved to a `domains` constant, checked for lookalikes
|
||||
/// against the full production config (not just the owning module).
|
||||
#[actix_rt::test]
|
||||
async fn production_config_rejects_lookalikes_of_migrated_origins() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in MIGRATED_ORIGINS {
|
||||
assert_exact_https_origin(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn full_config_failures(
|
||||
cors: &Cors,
|
||||
select: impl Fn(Env) -> bool,
|
||||
expect_rejected: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let mut failures = Vec::new();
|
||||
for &(_, env, origin) in ALLOWLIST {
|
||||
if select(env) && is_origin_allowed(cors, origin).await == expect_rejected {
|
||||
failures.push(origin);
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_2D;
|
||||
use domains::storyteller::STORYTELLER_2D;
|
||||
|
||||
pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://2d.storyteller.ai")
|
||||
.allowed_origin("https://2d.getartcraft.com")
|
||||
.allowed_origin(STORYTELLER_2D.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_2D.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-2d.netlify.app")
|
||||
@@ -15,3 +17,35 @@ pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
|
||||
.allowed_origin("http://localhost:5742") // If already started
|
||||
.allowed_origin("http://localhost:5743") // If already started
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::add_artcraft_2d;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_2d, is_production);
|
||||
assert_exact_https_origin(&cors, "https://2d.getartcraft.com").await;
|
||||
assert_exact_https_origin(&cors, "https://2d.storyteller.ai").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tauri_localhost_ports() {
|
||||
let cors = isolated_cors(add_artcraft_2d, true);
|
||||
for origin in ["http://localhost:5741", "http://localhost:5742", "http://localhost:5743"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "http://localhost:5744").await;
|
||||
assert_origin_invalid(&cors, "http://localhost:5173").await; // The 3D engine's port
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_2d, true);
|
||||
assert_netlify_project(&cors, "storyteller-2d.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_3D;
|
||||
use domains::storyteller::STORYTELLER_3D;
|
||||
|
||||
pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://3d.storyteller.ai")
|
||||
.allowed_origin("https://3d.getartcraft.com")
|
||||
.allowed_origin(STORYTELLER_3D.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_3D.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-3d.netlify.app")
|
||||
@@ -18,3 +20,48 @@ pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
|
||||
.allowed_origin("https://halide.tailce84f.ts.net") // TODO
|
||||
.allowed_origin("https://brandons-macbook-pro.taild62114.ts.net") // TODO
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::add_artcraft_3d;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_3d, is_production);
|
||||
assert_exact_https_origin(&cors, "https://3d.getartcraft.com").await;
|
||||
assert_exact_https_origin(&cors, "https://3d.storyteller.ai").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tauri_localhost_ports() {
|
||||
let cors = isolated_cors(add_artcraft_3d, true);
|
||||
for origin in ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "http://localhost:5176").await;
|
||||
assert_origin_invalid(&cors, "http://localhost:5741").await; // The 2D engine's port
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tailscale_hosts() {
|
||||
let cors = isolated_cors(add_artcraft_3d, true);
|
||||
for origin in [
|
||||
"https://macaroni-1.tailce84f.ts.net",
|
||||
"https://halide.tailce84f.ts.net",
|
||||
"https://brandons-macbook-pro.taild62114.ts.net",
|
||||
] {
|
||||
assert_exact_https_origin(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "https://other-machine.tailce84f.ts.net").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_3d, true);
|
||||
assert_netlify_project(&cors, "storyteller-3d.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,18 @@ pub fn add_artcraft_admin_dashboard(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "artcraft-dashboard.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_netlify_project, isolated_cors};
|
||||
|
||||
use super::add_artcraft_admin_dashboard;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_admin_dashboard, is_production);
|
||||
assert_netlify_project(&cors, "artcraft-dashboard.netlify.app").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use actix_cors::Cors;
|
||||
use domains::artcraft::ARTCRAFT_DESKTOP;
|
||||
|
||||
pub fn add_artcraft_desktop(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Stable identity sent by the Artcraft native HTTP bridge (dev and packaged apps).
|
||||
.allowed_origin(ARTCRAFT_DESKTOP.https_link_without_path())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::dev::Transform;
|
||||
use actix_web::http::{header, Method, StatusCode};
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::cors::build_cors_config;
|
||||
use crate::testing::{assert_origin_invalid, assert_origin_ok};
|
||||
|
||||
const DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn desktop_requests_and_preflights_allow_credentials() {
|
||||
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
|
||||
let service = build_cors_config(environment)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for method in [Method::GET, Method::POST, Method::OPTIONS] {
|
||||
let mut request = TestRequest::default()
|
||||
.uri("/v1/session")
|
||||
.insert_header((header::ORIGIN, DESKTOP_ORIGIN));
|
||||
if method == Method::OPTIONS {
|
||||
request = request
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
|
||||
}
|
||||
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), DESKTOP_ORIGIN);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn desktop_origin_is_an_exact_https_allowlist_entry() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in [
|
||||
"http://desktop.getartcraft.com",
|
||||
"https://desktop.getartcraft.com:8443",
|
||||
"https://desktop.getartcraft.com.example.com",
|
||||
"https://sub.desktop.getartcraft.com",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn existing_desktop_clients_remain_allowed() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in ["https://studio.storyteller.ai", "http://tauri.localhost", "tauri://localhost"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,34 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_WEBAPP;
|
||||
|
||||
pub fn add_artcraft_webapp(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Actual domains
|
||||
.allowed_origin("https://app.getartcraft.com")
|
||||
.allowed_origin(ARTCRAFT_WEBAPP.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "artcraft-webapp.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, isolated_cors};
|
||||
|
||||
use super::add_artcraft_webapp;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn webapp_domain() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_webapp, is_production);
|
||||
assert_exact_https_origin(&cors, "https://app.getartcraft.com").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_webapp, true);
|
||||
assert_netlify_project(&cors, "artcraft-webapp.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_AI;
|
||||
use domains::artcraft::ARTCRAFT_AI_WWW;
|
||||
use domains::artcraft::ARTCRAFT_WEBSITE;
|
||||
use domains::artcraft::ARTCRAFT_WEBSITE_WWW;
|
||||
|
||||
pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Actual domains
|
||||
.allowed_origin("https://getartcraft.com")
|
||||
.allowed_origin("https://www.getartcraft.com")
|
||||
.allowed_origin(ARTCRAFT_WEBSITE.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_WEBSITE_WWW.https_link_without_path())
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://artcraft.ai")
|
||||
.allowed_origin("https://www.artcraft.ai")
|
||||
.allowed_origin(ARTCRAFT_AI.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_AI_WWW.https_link_without_path())
|
||||
// Development against production
|
||||
.allowed_origin("http://localhost:4200")
|
||||
.allowed_origin("http://localhost:4201")
|
||||
@@ -18,3 +22,42 @@ pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "artcraft-website.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::add_artcraft_website;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn website_domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_website, is_production);
|
||||
for origin in [
|
||||
"https://getartcraft.com",
|
||||
"https://www.getartcraft.com",
|
||||
"https://artcraft.ai",
|
||||
"https://www.artcraft.ai",
|
||||
] {
|
||||
assert_exact_https_origin(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local frontend builds talk to the production API.
|
||||
#[actix_rt::test]
|
||||
async fn localhost_development_against_production() {
|
||||
let cors = isolated_cors(add_artcraft_website, true);
|
||||
for origin in ["http://localhost:4200", "http://localhost:4201", "http://localhost:4202"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "http://localhost:4203").await;
|
||||
assert_origin_invalid(&cors, "https://localhost:4200").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_website, true);
|
||||
assert_netlify_project(&cors, "artcraft-website.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,48 @@ pub fn add_development_only(cors: Cors) -> Cors {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::testing::{assert_origin_invalid, assert_origin_ok};
|
||||
|
||||
use super::add_development_only;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn any_localhost_is_allowed() {
|
||||
let cors = development_only_cors();
|
||||
for origin in [
|
||||
"http://localhost",
|
||||
"http://localhost:3000",
|
||||
"https://localhost:8443",
|
||||
"http://127.0.0.1",
|
||||
"http://127.0.0.1:5173",
|
||||
] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn non_localhost_is_rejected() {
|
||||
let cors = development_only_cors();
|
||||
for origin in [
|
||||
"https://getartcraft.com",
|
||||
"http://localhost.evil.example",
|
||||
"http://evil.localhost.example",
|
||||
"http://127.0.0.2",
|
||||
"http://192.168.1.10",
|
||||
"http://[::1]", // NB: IPv6 loopback isn't allowed today
|
||||
"not a url",
|
||||
"null",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn development_only_cors() -> Cors {
|
||||
add_development_only(Cors::default())
|
||||
.block_on_origin_mismatch(true)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
use actix_cors::Cors;
|
||||
use log::warn;
|
||||
use url::{Host, Url};
|
||||
use domains::fakeyou::FAKEYOU;
|
||||
use domains::fakeyou::FAKEYOU_API;
|
||||
use domains::fakeyou::FAKEYOU_ENGINE;
|
||||
use domains::fakeyou::FAKEYOU_STAGING;
|
||||
|
||||
pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
|
||||
if is_production {
|
||||
cors
|
||||
// Storyteller Engine (Production)
|
||||
.allowed_origin("https://engine.fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path())
|
||||
// FakeYou (Production)
|
||||
.allowed_origin("https://api.fakeyou.com")
|
||||
.allowed_origin("https://fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_API.https_link_without_path())
|
||||
.allowed_origin(FAKEYOU.https_link_without_path())
|
||||
// FakeYou (Staging)
|
||||
.allowed_origin("https://staging.fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_STAGING.https_link_without_path())
|
||||
// Allow Netlify domains within "fakeyou" project.
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
let maybe_url = origin.to_str()
|
||||
@@ -50,7 +54,7 @@ pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
|
||||
.allowed_origin("https://dev.fakeyou.com:7000") // Yarn default port
|
||||
.allowed_origin("https://dev.fakeyou.com:7001") // NB: Mac frontend
|
||||
// Storyteller Engine (Development)
|
||||
.allowed_origin("https://engine.fakeyou.com") // NB: We use prod for integration testing
|
||||
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path()) // NB: We use prod for integration testing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,3 +75,35 @@ pub fn add_legacy_trumped(cors: Cors, is_production: bool) -> Cors {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::{add_legacy_storyteller_stream, add_legacy_trumped, add_legacy_vocodes, add_power_stream};
|
||||
|
||||
// NB: The full per-environment origin lists are pinned in allowlist_regression_tests.
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn legacy_storyteller_stream_has_no_development_origins() {
|
||||
let cors = isolated_cors(add_legacy_storyteller_stream, false);
|
||||
assert_origin_invalid(&cors, "https://storyteller.stream").await;
|
||||
assert_origin_invalid(&cors, "https://create.storyteller.ai").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_hosts_are_exact() {
|
||||
let power_stream = isolated_cors(add_power_stream, true);
|
||||
assert_origin_ok(&power_stream, "https://power.stream").await;
|
||||
assert_origin_invalid(&power_stream, "http://power.stream").await;
|
||||
assert_origin_invalid(&power_stream, "https://power.stream.evil.example").await;
|
||||
|
||||
let vocodes = isolated_cors(add_legacy_vocodes, true);
|
||||
assert_origin_ok(&vocodes, "https://vo.codes").await;
|
||||
assert_origin_invalid(&vocodes, "https://evil.vo.codes").await;
|
||||
|
||||
let trumped = isolated_cors(add_legacy_trumped, true);
|
||||
assert_origin_ok(&trumped, "https://trumped.com").await;
|
||||
assert_origin_invalid(&trumped, "https://trumped.com:8443").await;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub (crate) mod artcraft_admin_dashboard;
|
||||
pub (crate) mod artcraft_2d;
|
||||
pub (crate) mod artcraft_3d;
|
||||
pub (crate) mod artcraft_desktop;
|
||||
pub (crate) mod artcraft_webapp;
|
||||
pub (crate) mod artcraft_website;
|
||||
pub (crate) mod development_only;
|
||||
@@ -11,4 +12,4 @@ pub (crate) mod storyteller;
|
||||
pub (crate) mod storyteller_board;
|
||||
pub (crate) mod storyteller_render;
|
||||
pub (crate) mod storyteller_studio;
|
||||
pub (crate) mod tauri;
|
||||
pub (crate) mod tauri;
|
||||
@@ -12,3 +12,25 @@ pub fn add_realseedance(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "real-seedance.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, isolated_cors};
|
||||
|
||||
use super::add_realseedance;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_realseedance, is_production);
|
||||
assert_exact_https_origin(&cors, "https://realseedance.com").await;
|
||||
assert_exact_https_origin(&cors, "https://www.realseedance.com").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_realseedance, true);
|
||||
assert_netlify_project(&cors, "real-seedance.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER;
|
||||
use domains::storyteller::STORYTELLER_API;
|
||||
use domains::storyteller::STORYTELLER_ENGINE;
|
||||
use domains::storyteller::STORYTELLER_STAGING;
|
||||
use domains::storyteller::STORYTELLER_STUDIO;
|
||||
|
||||
pub fn add_storyteller(cors: Cors, is_production: bool) -> Cors {
|
||||
if is_production {
|
||||
cors
|
||||
// Storyteller Engine (Production)
|
||||
.allowed_origin("https://engine.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_ENGINE.https_link_without_path())
|
||||
// Storyteller Studio (Production)
|
||||
.allowed_origin("https://studio.storyteller.ai")
|
||||
// DO NOT REMOVE: shipped ArtCraft desktop builds send this origin.
|
||||
// See deployed_desktop_client_origin_tests.rs.
|
||||
.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
|
||||
// Storyteller.ai (Production)
|
||||
.allowed_origin("https://api.storyteller.ai")
|
||||
.allowed_origin("https://storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_API.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER.https_link_without_path())
|
||||
// Storyteller.ai (Staging)
|
||||
.allowed_origin("https://staging.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_STAGING.https_link_without_path())
|
||||
// Allow Netlify domains within "storyteller-ai" project.
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-ai.netlify.app")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_BOARD;
|
||||
|
||||
pub fn add_storyteller_board(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://memeboard.ai")
|
||||
.allowed_origin("https://dingboard.ai")
|
||||
.allowed_origin("https://board.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_BOARD.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-board.netlify.app")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_RENDER;
|
||||
|
||||
pub fn add_storyteller_render(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://render.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_RENDER.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-render.netlify.app")
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_ANIMATE;
|
||||
use domains::storyteller::STORYTELLER_STUDIO;
|
||||
use domains::storyteller::STORYTELLER_STUDIO_STAGING;
|
||||
use domains::storyteller::STORYTELLER_STUDIO_TESTING;
|
||||
|
||||
pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors.allowed_origin("https://studio.storyteller.ai")
|
||||
.allowed_origin("https://studio-staging.studio.storyteller.ai")
|
||||
.allowed_origin("https://studio-testing.studio.storyteller.ai")
|
||||
cors
|
||||
// DO NOT REMOVE: shipped ArtCraft desktop builds send this origin.
|
||||
// See deployed_desktop_client_origin_tests.rs.
|
||||
.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER_STUDIO_STAGING.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER_STUDIO_TESTING.https_link_without_path())
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storytellerstudio.netlify.app")
|
||||
})
|
||||
@@ -13,7 +20,7 @@ pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "pipeline-gottagofast.netlify.app")
|
||||
})
|
||||
.allowed_origin("http://localhost:5173")
|
||||
.allowed_origin("https://animate.storyteller.ai") // NB: Gen2 Studio
|
||||
.allowed_origin(STORYTELLER_ANIMATE.https_link_without_path()) // NB: Gen2 Studio
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,5 +5,90 @@ pub fn add_tauri(cors: Cors, _is_production: bool) -> Cors {
|
||||
// Tauri Windows
|
||||
.allowed_origin("http://tauri.localhost")
|
||||
// Tauri Mac
|
||||
// DO NOT REMOVE: shipped ArtCraft desktop builds send this origin.
|
||||
// See deployed_desktop_client_origin_tests.rs.
|
||||
.allowed_origin("tauri://localhost")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::dev::Transform;
|
||||
use actix_web::http::{header, Method, StatusCode};
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::cors::build_cors_config;
|
||||
use crate::testing::assert_origin_invalid;
|
||||
|
||||
const WINDOWS_ORIGIN: &str = "http://tauri.localhost";
|
||||
const MAC_ORIGIN: &str = "tauri://localhost";
|
||||
|
||||
mod windows {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn requests_and_preflights_allow_credentials() {
|
||||
assert_requests_and_preflights_allow_credentials(WINDOWS_ORIGIN).await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_rejects_origin_variants() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in [
|
||||
"https://tauri.localhost",
|
||||
"http://tauri.localhost:8080",
|
||||
"http://tauri.localhost.example.com",
|
||||
"http://sub.tauri.localhost",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod mac {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn requests_and_preflights_allow_credentials() {
|
||||
assert_requests_and_preflights_allow_credentials(MAC_ORIGIN).await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_rejects_origin_variants() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in [
|
||||
"http://localhost",
|
||||
"https://localhost",
|
||||
"tauri://localhost:8080",
|
||||
"tauri://localhost.example.com",
|
||||
"tauri://sub.localhost",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_requests_and_preflights_allow_credentials(origin: &str) {
|
||||
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
|
||||
let service = build_cors_config(environment)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for method in [Method::GET, Method::POST, Method::OPTIONS] {
|
||||
let mut request = TestRequest::default()
|
||||
.uri("/v1/session")
|
||||
.insert_header((header::ORIGIN, origin));
|
||||
if method == Method::OPTIONS {
|
||||
request = request
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
|
||||
}
|
||||
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK, "Origin: {}", origin);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), origin);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use log::info;
|
||||
use crate::configs::artcraft_2d::add_artcraft_2d;
|
||||
use crate::configs::artcraft_3d::add_artcraft_3d;
|
||||
use crate::configs::artcraft_admin_dashboard::add_artcraft_admin_dashboard;
|
||||
use crate::configs::artcraft_desktop::add_artcraft_desktop;
|
||||
use crate::configs::artcraft_webapp::add_artcraft_webapp;
|
||||
use crate::configs::artcraft_website::add_artcraft_website;
|
||||
use crate::configs::development_only::add_development_only;
|
||||
@@ -48,6 +49,7 @@ fn do_build_cors_config(is_production: bool) -> Cors {
|
||||
|
||||
// Artcraft
|
||||
cors = add_tauri(cors, is_production);
|
||||
cors = add_artcraft_desktop(cors, is_production);
|
||||
cors = add_artcraft_2d(cors, is_production);
|
||||
cors = add_artcraft_3d(cors, is_production);
|
||||
cors = add_artcraft_webapp(cors, is_production);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! DO NOT REMOVE THESE ORIGINS: shipped ArtCraft desktop builds depend on them.
|
||||
//!
|
||||
//! Old ArtCraft desktop builds in the wild send one of these `Origin` headers:
|
||||
//!
|
||||
//! * `https://studio.storyteller.ai`
|
||||
//! * `tauri://localhost` (macOS Tauri webview)
|
||||
//!
|
||||
//! Those builds can't be updated server-side. If the API stops allowing these
|
||||
//! origins, those installs break: every credentialed request (login, session,
|
||||
//! generation) fails CORS in the webview.
|
||||
//!
|
||||
//! These tests live in their own module on purpose, apart from the Storyteller
|
||||
//! and Tauri configs, so reworking or deleting those configs (or their tests)
|
||||
//! can't silently remove this protection. They use string literals, not
|
||||
//! `domains` constants, so renaming a constant can't change what they check.
|
||||
|
||||
use actix_web::dev::Transform;
|
||||
use actix_web::http::{header, Method, StatusCode};
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::cors::build_cors_config;
|
||||
|
||||
const STORYTELLER_STUDIO_ORIGIN: &str = "https://studio.storyteller.ai";
|
||||
const TAURI_MAC_ORIGIN: &str = "tauri://localhost";
|
||||
|
||||
const ENVIRONMENTS: [ServerEnvironment; 2] = [ServerEnvironment::Production, ServerEnvironment::Development];
|
||||
|
||||
mod storyteller_studio_origin {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn credentialed_requests_are_allowed() {
|
||||
assert_credentialed_requests_allowed(STORYTELLER_STUDIO_ORIGIN).await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn preflights_are_allowed() {
|
||||
assert_preflights_allowed(STORYTELLER_STUDIO_ORIGIN).await;
|
||||
}
|
||||
}
|
||||
|
||||
mod tauri_mac_origin {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn credentialed_requests_are_allowed() {
|
||||
assert_credentialed_requests_allowed(TAURI_MAC_ORIGIN).await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn preflights_are_allowed() {
|
||||
assert_preflights_allowed(TAURI_MAC_ORIGIN).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple requests in every environment and method get the origin echoed
|
||||
/// back with credentials allowed, which is what the webview requires.
|
||||
async fn assert_credentialed_requests_allowed(origin: &str) {
|
||||
for environment in ENVIRONMENTS {
|
||||
let service = build_cors_config(environment)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for method in [Method::GET, Method::POST, Method::PUT, Method::DELETE] {
|
||||
let request = TestRequest::default()
|
||||
.method(method.clone())
|
||||
.uri("/v1/session")
|
||||
.insert_header((header::ORIGIN, origin))
|
||||
.to_srv_request();
|
||||
let response = test::call_service(&service, request).await;
|
||||
|
||||
let context = format!(
|
||||
"Shipped ArtCraft desktop builds send Origin {} ({:?}, {}). DO NOT REMOVE this origin from the CORS config.",
|
||||
origin, environment, method);
|
||||
assert_eq!(response.status(), StatusCode::OK, "{}", context);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).map(|v| v.to_str().unwrap()), Some(origin), "{}", context);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).map(|v| v.to_str().unwrap()), Some("true"), "{}", context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflights for the methods and headers the desktop app uses are accepted.
|
||||
async fn assert_preflights_allowed(origin: &str) {
|
||||
for environment in ENVIRONMENTS {
|
||||
let service = build_cors_config(environment)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for requested_method in ["GET", "POST", "PUT", "DELETE"] {
|
||||
let request = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.uri("/v1/session")
|
||||
.insert_header((header::ORIGIN, origin))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, requested_method))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session"))
|
||||
.to_srv_request();
|
||||
let response = test::call_service(&service, request).await;
|
||||
|
||||
let context = format!(
|
||||
"Shipped ArtCraft desktop builds send Origin {} ({:?}, preflight {}). DO NOT REMOVE this origin from the CORS config.",
|
||||
origin, environment, requested_method);
|
||||
assert_eq!(response.status(), StatusCode::OK, "{}", context);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).map(|v| v.to_str().unwrap()), Some(origin), "{}", context);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).map(|v| v.to_str().unwrap()), Some("true"), "{}", context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,11 @@ pub (crate) mod util;
|
||||
|
||||
#[cfg(test)]
|
||||
pub (crate) mod testing;
|
||||
|
||||
#[cfg(test)]
|
||||
mod allowlist_regression_tests;
|
||||
|
||||
// Origins sent by shipped ArtCraft desktop builds. Kept apart from the configs
|
||||
// they test so those can't be refactored away with them.
|
||||
#[cfg(test)]
|
||||
mod deployed_desktop_client_origin_tests;
|
||||
@@ -7,6 +7,53 @@ use actix_web::test;
|
||||
use actix_web::test::TestRequest;
|
||||
use speculoos::asserting;
|
||||
|
||||
/// A config containing ONLY one module's origins, finished the same way as
|
||||
/// the production config (mismatched origins get a 400). Testing modules in
|
||||
/// isolation proves each module allows its own origins, rather than passing
|
||||
/// because some other module happens to allow them too.
|
||||
pub (crate) fn isolated_cors(add_origins: fn(Cors, bool) -> Cors, is_production: bool) -> Cors {
|
||||
add_origins(Cors::default(), is_production)
|
||||
.block_on_origin_mismatch(true)
|
||||
}
|
||||
|
||||
/// Whether `cors` allows a simple request from `origin`, for tests that check
|
||||
/// many origins and report every failure at once.
|
||||
pub (crate) async fn is_origin_allowed(cors: &Cors, origin: &str) -> bool {
|
||||
let status = make_test_request(cors, origin).await.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::BAD_REQUEST,
|
||||
"unexpected status {} for origin {}", status, origin);
|
||||
status == StatusCode::OK
|
||||
}
|
||||
|
||||
/// Assert that `cors` allows the exact `https://` origin but none of its
|
||||
/// lookalikes: plain http, another port, a suffix attack, or a sub-subdomain.
|
||||
/// Don't use this for Netlify hosts: their matcher checks the hostname only.
|
||||
pub (crate) async fn assert_exact_https_origin(cors: &Cors, origin: &str) {
|
||||
let host = origin.strip_prefix("https://")
|
||||
.unwrap_or_else(|| panic!("expected an https:// origin, got {}", origin));
|
||||
|
||||
assert_origin_ok(cors, origin).await;
|
||||
assert_origin_invalid(cors, &format!("http://{}", host)).await;
|
||||
assert_origin_invalid(cors, &format!("https://{}:8443", host)).await;
|
||||
assert_origin_invalid(cors, &format!("https://{}.evil.example", host)).await;
|
||||
assert_origin_invalid(cors, &format!("https://evil.{}", host)).await;
|
||||
}
|
||||
|
||||
/// Assert that `cors` allows a Netlify project's main, branch and
|
||||
/// deploy-preview hosts, and rejects other projects' hosts.
|
||||
pub (crate) async fn assert_netlify_project(cors: &Cors, netlify_hostname: &str) {
|
||||
assert_origin_ok(cors, &format!("https://{}", netlify_hostname)).await;
|
||||
assert_origin_ok(cors, &format!("https://my-branch--{}", netlify_hostname)).await;
|
||||
assert_origin_ok(cors, &format!("https://deploy-preview-123--{}", netlify_hostname)).await;
|
||||
|
||||
assert_origin_invalid(cors, "https://unrelated-project.netlify.app").await;
|
||||
assert_origin_invalid(cors, "https://deploy-preview-123--unrelated-project.netlify.app").await;
|
||||
assert_origin_invalid(cors, &format!("https://{}.evil.example", netlify_hostname)).await;
|
||||
// A hyphen alone isn't a branch separator; Netlify branch hosts use "--".
|
||||
assert_origin_invalid(cors, &format!("https://evil-{}", netlify_hostname)).await;
|
||||
}
|
||||
|
||||
pub (crate) async fn assert_origin_ok(cors: &Cors, hostname: &str) {
|
||||
let response = make_test_request(cors, hostname).await;
|
||||
asserting(&format!("Hostname {} is valid", hostname))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "domains"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "domains"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Adds `Domain::https_url()`, returning a cached `&'static url::Url`.
|
||||
url = ["dep:url"]
|
||||
|
||||
# No dependencies by default: the domain constants and their string forms are
|
||||
# built entirely at compile time.
|
||||
[dependencies]
|
||||
url = { workspace = true, optional = true }
|
||||
@@ -0,0 +1,69 @@
|
||||
//! ArtCraft domains.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Public marketing website (Next.js).
|
||||
pub const ARTCRAFT_WEBSITE: Domain = domain!("getartcraft.com");
|
||||
pub const ARTCRAFT_WEBSITE_WWW: Domain = domain!("www.getartcraft.com");
|
||||
|
||||
/// Browser web application.
|
||||
pub const ARTCRAFT_WEBAPP: Domain = domain!("app.getartcraft.com");
|
||||
|
||||
/// Desktop app landing page.
|
||||
pub const ARTCRAFT_DESKTOP: Domain = domain!("desktop.getartcraft.com");
|
||||
|
||||
/// HTTP API.
|
||||
pub const ARTCRAFT_API: Domain = domain!("api.getartcraft.com");
|
||||
|
||||
pub const ARTCRAFT_2D: Domain = domain!("2d.getartcraft.com");
|
||||
pub const ARTCRAFT_3D: Domain = domain!("3d.getartcraft.com");
|
||||
|
||||
/// Alternate marketing domain.
|
||||
pub const ARTCRAFT_AI: Domain = domain!("artcraft.ai");
|
||||
pub const ARTCRAFT_AI_WWW: Domain = domain!("www.artcraft.ai");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(ARTCRAFT_WEBSITE.https_link_without_path(), "https://getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_WEBSITE_WWW.https_link_without_path(), "https://www.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp() {
|
||||
assert_eq!(ARTCRAFT_WEBAPP.https_link_without_path(), "https://app.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop() {
|
||||
assert_eq!(ARTCRAFT_DESKTOP.bare_domain(), "desktop.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_link_with_root_path(), "https://desktop.getartcraft.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(ARTCRAFT_API.https_link_without_path(), "https://api.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_d_and_three_d() {
|
||||
assert_eq!(ARTCRAFT_2D.https_link_without_path(), "https://2d.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_3D.https_link_without_path(), "https://3d.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artcraft_ai() {
|
||||
assert_eq!(ARTCRAFT_AI.https_link_without_path(), "https://artcraft.ai");
|
||||
assert_eq!(ARTCRAFT_AI_WWW.https_link_without_path(), "https://www.artcraft.ai");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
#[test]
|
||||
fn desktop_url() {
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_url().as_str(), "https://desktop.getartcraft.com/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/// A validated domain name, with its `https://` link forms precomputed at
|
||||
/// compile time. Build one with the [`domain!`](crate::domain) macro:
|
||||
///
|
||||
/// ```
|
||||
/// use domains::{domain, Domain};
|
||||
///
|
||||
/// const EXAMPLE: Domain = domain!("desktop.example.com");
|
||||
///
|
||||
/// assert_eq!(EXAMPLE.bare_domain(), "desktop.example.com");
|
||||
/// assert_eq!(EXAMPLE.https_link_without_path(), "https://desktop.example.com");
|
||||
/// assert_eq!(EXAMPLE.https_link_with_root_path(), "https://desktop.example.com/");
|
||||
/// ```
|
||||
///
|
||||
/// An invalid literal fails to compile:
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// const BAD: domains::Domain = domains::domain!("https://example.com");
|
||||
/// ```
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Domain {
|
||||
bare: &'static str,
|
||||
https_without_path: &'static str,
|
||||
https_with_root_path: &'static str,
|
||||
}
|
||||
|
||||
/// Declares a [`Domain`] from a string literal. Validation runs at compile
|
||||
/// time: an invalid domain is a build error, never a runtime panic.
|
||||
#[macro_export]
|
||||
macro_rules! domain {
|
||||
($domain:literal) => {
|
||||
$crate::Domain::__from_macro(
|
||||
$domain,
|
||||
::core::concat!("https://", $domain),
|
||||
::core::concat!("https://", $domain, "/"),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
impl Domain {
|
||||
/// Only for the `domain!` macro, which guarantees the three strings agree.
|
||||
/// Panics (a compile error in const context) on an invalid domain.
|
||||
#[doc(hidden)]
|
||||
pub const fn __from_macro(
|
||||
bare: &'static str,
|
||||
https_without_path: &'static str,
|
||||
https_with_root_path: &'static str,
|
||||
) -> Self {
|
||||
crate::validate::assert_valid_domain(bare);
|
||||
Self { bare, https_without_path, https_with_root_path }
|
||||
}
|
||||
|
||||
/// eg. `desktop.getartcraft.com`
|
||||
pub const fn bare_domain(&self) -> &'static str {
|
||||
self.bare
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com`. This is also the browser `Origin`
|
||||
/// header value, so use it for CORS and origin allowlists.
|
||||
pub const fn https_link_without_path(&self) -> &'static str {
|
||||
self.https_without_path
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com/`
|
||||
pub const fn https_link_with_root_path(&self) -> &'static str {
|
||||
self.https_with_root_path
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com/` as a `Url`.
|
||||
///
|
||||
/// Parsed on first use per domain, then served from a process-wide cache,
|
||||
/// so every call returns the same `&'static Url`.
|
||||
#[cfg(feature = "url")]
|
||||
pub fn https_url(&self) -> &'static url::Url {
|
||||
crate::url_cache::https_url(self.bare, self.https_with_root_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Domain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Domain({})", self.bare)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Domain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.bare)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Domain;
|
||||
|
||||
const TEST_DOMAIN: Domain = domain!("desktop.example.com");
|
||||
|
||||
#[test]
|
||||
fn bare_domain() {
|
||||
assert_eq!(TEST_DOMAIN.bare_domain(), "desktop.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_links() {
|
||||
assert_eq!(TEST_DOMAIN.https_link_without_path(), "https://desktop.example.com");
|
||||
assert_eq!(TEST_DOMAIN.https_link_with_root_path(), "https://desktop.example.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_in_const_context() {
|
||||
const ORIGINS: &[&str] = &[TEST_DOMAIN.https_link_without_path()];
|
||||
assert_eq!(ORIGINS, &["https://desktop.example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_debug() {
|
||||
assert_eq!(TEST_DOMAIN.to_string(), "desktop.example.com");
|
||||
assert_eq!(format!("{TEST_DOMAIN:?}"), "Domain(desktop.example.com)");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
mod url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_url_has_root_path() {
|
||||
let url = TEST_DOMAIN.https_url();
|
||||
assert_eq!(url.as_str(), "https://desktop.example.com/");
|
||||
assert_eq!(url.scheme(), "https");
|
||||
assert_eq!(url.host_str(), Some("desktop.example.com"));
|
||||
assert_eq!(url.path(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_is_parsed_once_and_shared() {
|
||||
let first = TEST_DOMAIN.https_url();
|
||||
let second = TEST_DOMAIN.https_url();
|
||||
assert!(std::ptr::eq(first, second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_is_shared_across_threads() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<Domain>();
|
||||
assert_send_sync::<&'static url::Url>();
|
||||
|
||||
// Borrowed into 'static threads with no Arc or cloning; every thread
|
||||
// sees the same parsed instance.
|
||||
let main_thread_url = TEST_DOMAIN.https_url();
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| std::thread::spawn(|| TEST_DOMAIN.https_url()))
|
||||
.collect();
|
||||
for handle in handles {
|
||||
assert!(std::ptr::eq(handle.join().unwrap(), main_thread_url));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_matches_string_forms() {
|
||||
let url = TEST_DOMAIN.https_url();
|
||||
assert_eq!(url.as_str(), TEST_DOMAIN.https_link_with_root_path());
|
||||
assert_eq!(url.origin().ascii_serialization(), TEST_DOMAIN.https_link_without_path());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! FakeYou domains.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Main website.
|
||||
pub const FAKEYOU: Domain = domain!("fakeyou.com");
|
||||
|
||||
/// HTTP API.
|
||||
pub const FAKEYOU_API: Domain = domain!("api.fakeyou.com");
|
||||
|
||||
/// Engine frontend (also used against production for integration testing).
|
||||
pub const FAKEYOU_ENGINE: Domain = domain!("engine.fakeyou.com");
|
||||
|
||||
pub const FAKEYOU_STAGING: Domain = domain!("staging.fakeyou.com");
|
||||
|
||||
/// Local development hosts (resolve to a developer machine).
|
||||
pub const FAKEYOU_DEV: Domain = domain!("dev.fakeyou.com");
|
||||
pub const FAKEYOU_DEV_PROXY: Domain = domain!("devproxy.fakeyou.com");
|
||||
|
||||
/// Production media CDN. Serves both FakeYou and Storyteller media.
|
||||
pub const FAKEYOU_CDN: Domain = domain!("cdn-2.fakeyou.com");
|
||||
|
||||
/// Development media CDN (a Cloudflare R2 public bucket). Serves both FakeYou
|
||||
/// and Storyteller development media, like `FAKEYOU_CDN` does in production.
|
||||
pub const FAKEYOU_DEV_CDN: Domain = domain!("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(FAKEYOU.bare_domain(), "fakeyou.com");
|
||||
assert_eq!(FAKEYOU.https_link_without_path(), "https://fakeyou.com");
|
||||
assert_eq!(FAKEYOU.https_link_with_root_path(), "https://fakeyou.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(FAKEYOU_API.https_link_without_path(), "https://api.fakeyou.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_and_staging() {
|
||||
assert_eq!(FAKEYOU_ENGINE.https_link_without_path(), "https://engine.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_STAGING.https_link_without_path(), "https://staging.fakeyou.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(FAKEYOU_DEV.bare_domain(), "dev.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_DEV_PROXY.bare_domain(), "devproxy.fakeyou.com");
|
||||
}
|
||||
|
||||
mod cdn {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn production() {
|
||||
assert_eq!(FAKEYOU_CDN.bare_domain(), "cdn-2.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_CDN.https_link_without_path(), "https://cdn-2.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_CDN.https_link_with_root_path(), "https://cdn-2.fakeyou.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(FAKEYOU_DEV_CDN.https_link_without_path(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
#[test]
|
||||
fn urls() {
|
||||
assert_eq!(FAKEYOU_CDN.https_url().as_str(), "https://cdn-2.fakeyou.com/");
|
||||
assert_eq!(FAKEYOU_DEV_CDN.https_url().as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! First-party domain names, validated at compile time.
|
||||
//!
|
||||
//! ```
|
||||
//! use domains::artcraft::ARTCRAFT_DESKTOP;
|
||||
//!
|
||||
//! assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
|
||||
//! ```
|
||||
//!
|
||||
//! Enable the `url` feature for `Domain::https_url()`, a cached `&'static url::Url`.
|
||||
|
||||
pub mod artcraft;
|
||||
pub mod fakeyou;
|
||||
pub mod storyteller;
|
||||
|
||||
mod domain;
|
||||
mod validate;
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
mod url_cache;
|
||||
|
||||
pub use domain::Domain;
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Storyteller domains. Storyteller media is served from `fakeyou::FAKEYOU_CDN`.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Main website.
|
||||
pub const STORYTELLER: Domain = domain!("storyteller.ai");
|
||||
|
||||
/// HTTP API.
|
||||
pub const STORYTELLER_API: Domain = domain!("api.storyteller.ai");
|
||||
|
||||
pub const STORYTELLER_ENGINE: Domain = domain!("engine.storyteller.ai");
|
||||
pub const STORYTELLER_STAGING: Domain = domain!("staging.storyteller.ai");
|
||||
|
||||
/// Storyteller Studio and its deploy environments.
|
||||
///
|
||||
/// NB: Shipped ArtCraft desktop builds send `https://studio.storyteller.ai` as
|
||||
/// their CORS origin, so it must stay allowed (see `actix_cors_configs`).
|
||||
pub const STORYTELLER_STUDIO: Domain = domain!("studio.storyteller.ai");
|
||||
pub const STORYTELLER_STUDIO_STAGING: Domain = domain!("studio-staging.studio.storyteller.ai");
|
||||
pub const STORYTELLER_STUDIO_TESTING: Domain = domain!("studio-testing.studio.storyteller.ai");
|
||||
|
||||
/// Gen2 Studio.
|
||||
pub const STORYTELLER_ANIMATE: Domain = domain!("animate.storyteller.ai");
|
||||
|
||||
pub const STORYTELLER_BOARD: Domain = domain!("board.storyteller.ai");
|
||||
pub const STORYTELLER_RENDER: Domain = domain!("render.storyteller.ai");
|
||||
|
||||
/// Legacy hosts for the ArtCraft 2D and 3D apps.
|
||||
pub const STORYTELLER_2D: Domain = domain!("2d.storyteller.ai");
|
||||
pub const STORYTELLER_3D: Domain = domain!("3d.storyteller.ai");
|
||||
|
||||
/// Local development hosts (resolve to a developer machine).
|
||||
pub const STORYTELLER_DEV: Domain = domain!("dev.storyteller.ai");
|
||||
pub const STORYTELLER_DEV_PROXY: Domain = domain!("devproxy.storyteller.ai");
|
||||
|
||||
/// Storyteller Studio static assets.
|
||||
pub const STORYTELLER_CDN: Domain = domain!("cdn.storyteller.ai");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(STORYTELLER.bare_domain(), "storyteller.ai");
|
||||
assert_eq!(STORYTELLER.https_link_without_path(), "https://storyteller.ai");
|
||||
assert_eq!(STORYTELLER.https_link_with_root_path(), "https://storyteller.ai/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(STORYTELLER_API.https_link_without_path(), "https://api.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio() {
|
||||
assert_eq!(STORYTELLER_STUDIO.https_link_without_path(), "https://studio.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_STUDIO_STAGING.https_link_without_path(), "https://studio-staging.studio.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_STUDIO_TESTING.https_link_without_path(), "https://studio-testing.studio.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps() {
|
||||
assert_eq!(STORYTELLER_BOARD.https_link_without_path(), "https://board.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_RENDER.https_link_without_path(), "https://render.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_2D.https_link_without_path(), "https://2d.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_3D.https_link_without_path(), "https://3d.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(STORYTELLER_DEV.bare_domain(), "dev.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_DEV_PROXY.bare_domain(), "devproxy.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn() {
|
||||
assert_eq!(STORYTELLER_CDN.https_link_without_path(), "https://cdn.storyteller.ai");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Process-wide cache backing `Domain::https_url()`.
|
||||
//!
|
||||
//! `Url::parse` can't run at compile time, and `Domain` stays free of
|
||||
//! interior mutability so its constants remain usable in `const` contexts.
|
||||
//! So each domain's `Url` is parsed on first use and leaked into this map.
|
||||
//! Domains only come from `domain!` literals, so the set (and the leak) is
|
||||
//! bounded by the constants in the source code.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use url::Url;
|
||||
|
||||
static URLS: Mutex<BTreeMap<&'static str, &'static Url>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
pub(crate) fn https_url(bare: &'static str, https_with_root_path: &'static str) -> &'static Url {
|
||||
// The map is only ever inserted into, so a poisoned lock still holds
|
||||
// valid entries.
|
||||
let mut urls = URLS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
urls.entry(bare).or_insert_with(|| {
|
||||
// The domain was validated at compile time, so this parse can't fail.
|
||||
let url = Url::parse(https_with_root_path).expect("compile-time validated domain should parse");
|
||||
Box::leak(Box::new(url))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Compile-time domain name validation.
|
||||
//!
|
||||
//! `assert_valid_domain` is a `const fn` that panics on an invalid name. The
|
||||
//! `domain!` macro calls it while evaluating a `const`/`static`, where a panic
|
||||
//! is a compile error, so a bad domain literal never builds.
|
||||
|
||||
const MAX_DOMAIN_LEN: usize = 253;
|
||||
const MAX_LABEL_LEN: usize = 63;
|
||||
|
||||
/// Panics (a compile error in const context) unless `domain` is a canonical,
|
||||
/// fully-qualified DNS name: lowercase `a-z`, `0-9` and `-` labels separated
|
||||
/// by single dots, at least two labels, no scheme, port, path or trailing dot,
|
||||
/// and a non-numeric top-level label (so IP addresses are rejected).
|
||||
pub const fn assert_valid_domain(domain: &str) {
|
||||
match check_domain(domain) {
|
||||
Ok(()) => {}
|
||||
Err(DomainError::Empty) => panic!("invalid domain: empty"),
|
||||
Err(DomainError::TooLong) => panic!("invalid domain: longer than 253 bytes"),
|
||||
Err(DomainError::EmptyLabel) => panic!("invalid domain: empty label (leading, trailing or doubled dot)"),
|
||||
Err(DomainError::LabelTooLong) => panic!("invalid domain: label longer than 63 bytes"),
|
||||
Err(DomainError::InvalidCharacter) => panic!("invalid domain: only lowercase a-z, 0-9, '-' and '.' are allowed"),
|
||||
Err(DomainError::HyphenAtLabelEdge) => panic!("invalid domain: label starts or ends with '-'"),
|
||||
Err(DomainError::SingleLabel) => panic!("invalid domain: needs at least two labels (eg. example.com)"),
|
||||
Err(DomainError::NumericTopLevelLabel) => panic!("invalid domain: top-level label is numeric (IP addresses are not domains)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DomainError {
|
||||
Empty,
|
||||
TooLong,
|
||||
EmptyLabel,
|
||||
LabelTooLong,
|
||||
InvalidCharacter,
|
||||
HyphenAtLabelEdge,
|
||||
SingleLabel,
|
||||
NumericTopLevelLabel,
|
||||
}
|
||||
|
||||
pub(crate) const fn check_domain(domain: &str) -> Result<(), DomainError> {
|
||||
let bytes = domain.as_bytes();
|
||||
if bytes.is_empty() {
|
||||
return Err(DomainError::Empty);
|
||||
}
|
||||
if bytes.len() > MAX_DOMAIN_LEN {
|
||||
return Err(DomainError::TooLong);
|
||||
}
|
||||
|
||||
let mut labels = 0;
|
||||
let mut label_start = 0;
|
||||
let mut last_label_start = 0;
|
||||
let mut i = 0;
|
||||
// Walk one past the end so the final label is checked like the others.
|
||||
while i <= bytes.len() {
|
||||
if i == bytes.len() || bytes[i] == b'.' {
|
||||
match check_label(bytes, label_start, i) {
|
||||
Ok(()) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
labels += 1;
|
||||
last_label_start = label_start;
|
||||
label_start = i + 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if labels < 2 {
|
||||
return Err(DomainError::SingleLabel);
|
||||
}
|
||||
if is_all_digits(bytes, last_label_start, bytes.len()) {
|
||||
return Err(DomainError::NumericTopLevelLabel);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks `bytes[start..end]` as a single label.
|
||||
const fn check_label(bytes: &[u8], start: usize, end: usize) -> Result<(), DomainError> {
|
||||
let len = end - start;
|
||||
if len == 0 {
|
||||
return Err(DomainError::EmptyLabel);
|
||||
}
|
||||
if len > MAX_LABEL_LEN {
|
||||
return Err(DomainError::LabelTooLong);
|
||||
}
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
match bytes[i] {
|
||||
b'a'..=b'z' | b'0'..=b'9' | b'-' => {}
|
||||
_ => return Err(DomainError::InvalidCharacter),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if bytes[start] == b'-' || bytes[end - 1] == b'-' {
|
||||
return Err(DomainError::HyphenAtLabelEdge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn is_all_digits(bytes: &[u8], start: usize, end: usize) -> bool {
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
if !bytes[i].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DomainError::*;
|
||||
use super::check_domain;
|
||||
|
||||
mod valid {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apex_domain() {
|
||||
assert_eq!(check_domain("getartcraft.com"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subdomains() {
|
||||
assert_eq!(check_domain("desktop.getartcraft.com"), Ok(()));
|
||||
assert_eq!(check_domain("studio-staging.studio.storyteller.ai"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_and_hyphens_inside_labels() {
|
||||
assert_eq!(check_domain("cdn-2.fakeyou.com"), Ok(()));
|
||||
assert_eq!(check_domain("3d.getartcraft.com"), Ok(()));
|
||||
assert_eq!(check_domain("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lengths() {
|
||||
let label_63 = "a".repeat(63);
|
||||
assert_eq!(check_domain(&format!("{label_63}.com")), Ok(()));
|
||||
|
||||
// 63 + 1 + 63 + 1 + 63 + 1 + 61 = 253
|
||||
let domain_253 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(61));
|
||||
assert_eq!(domain_253.len(), 253);
|
||||
assert_eq!(check_domain(&domain_253), Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
mod invalid {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
assert_eq!(check_domain(""), Err(Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_long() {
|
||||
let label_63 = "a".repeat(63);
|
||||
let domain_254 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(62));
|
||||
assert_eq!(check_domain(&domain_254), Err(TooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_labels() {
|
||||
assert_eq!(check_domain(".getartcraft.com"), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("getartcraft.com."), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("desktop..getartcraft.com"), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("."), Err(EmptyLabel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_too_long() {
|
||||
assert_eq!(check_domain(&format!("{}.com", "a".repeat(64))), Err(LabelTooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase() {
|
||||
assert_eq!(check_domain("GetArtCraft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_port_path_and_whitespace() {
|
||||
assert_eq!(check_domain("https://getartcraft.com"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain("getartcraft.com:443"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain("getartcraft.com/"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain(" getartcraft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii() {
|
||||
assert_eq!(check_domain("artcräft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscore() {
|
||||
assert_eq!(check_domain("dev_proxy.fakeyou.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphen_at_label_edge() {
|
||||
assert_eq!(check_domain("-desktop.getartcraft.com"), Err(HyphenAtLabelEdge));
|
||||
assert_eq!(check_domain("desktop-.getartcraft.com"), Err(HyphenAtLabelEdge));
|
||||
assert_eq!(check_domain("getartcraft.com-"), Err(HyphenAtLabelEdge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_label() {
|
||||
assert_eq!(check_domain("localhost"), Err(SingleLabel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_addresses() {
|
||||
assert_eq!(check_domain("127.0.0.1"), Err(NumericTopLevelLabel));
|
||||
assert_eq!(check_domain("example.123"), Err(NumericTopLevelLabel));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
use anyhow::bail;
|
||||
use log::{error, info};
|
||||
use std::io::Write;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::ffmpeg::run_thumbnail_ffmpeg::run_thumbnail_ffmpeg;
|
||||
|
||||
pub struct FfmpegVideoFirstFrameToJpgThumbnailArgs<I: AsRef<Path>, O: AsRef<Path>> {
|
||||
pub input_video_path: I,
|
||||
pub output_jpg_path: O,
|
||||
@@ -24,26 +24,20 @@ pub fn ffmpeg_video_first_frame_to_jpg_thumbnail<I: AsRef<Path>, O: AsRef<Path>>
|
||||
command
|
||||
.arg("-nostdin")
|
||||
.arg("-y")
|
||||
.arg("-loglevel").arg("error")
|
||||
.arg("-nostats")
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-filter_threads").arg("1")
|
||||
.arg("-sseof").arg("-1")
|
||||
.arg("-i").arg(args.input_video_path.as_ref())
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-vf").arg("scale=320:320:force_original_aspect_ratio=decrease")
|
||||
.arg("-vframes").arg("1")
|
||||
.arg(args.output_jpg_path.as_ref());
|
||||
|
||||
info!("Calling ffmpeg (jpg thumbnail)...");
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
error!("bad exit status: {}", output.status);
|
||||
|
||||
let _r = std::io::stdout().write_all(&output.stdout);
|
||||
let _r = std::io::stderr().write_all(&output.stderr);
|
||||
|
||||
bail!("ffmpeg jpg thumbnail failed: {:?}", output.status.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
run_thumbnail_ffmpeg(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use anyhow::bail;
|
||||
use log::{error, info};
|
||||
use std::io::Write;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::ffmpeg::run_thumbnail_ffmpeg::run_thumbnail_ffmpeg;
|
||||
|
||||
pub struct FfmpegVideoGifPreviewArgs<I: AsRef<Path>, O: AsRef<Path>> {
|
||||
pub input_video_path: I,
|
||||
pub output_gif_path: O,
|
||||
@@ -13,7 +13,7 @@ pub struct FfmpegVideoGifPreviewArgs<I: AsRef<Path>, O: AsRef<Path>> {
|
||||
|
||||
/// Extract a short animated GIF preview from a video file.
|
||||
///
|
||||
/// Takes the first 5 seconds, resampled to 10 fps, scaled to 360px wide
|
||||
/// Takes the first 5 seconds, resampled to 10 fps, scaled to fit within 360x360
|
||||
/// (preserving aspect ratio), with an optimized color palette.
|
||||
pub fn ffmpeg_video_gif_preview<I: AsRef<Path>, O: AsRef<Path>>(
|
||||
args: FfmpegVideoGifPreviewArgs<I, O>,
|
||||
@@ -23,27 +23,22 @@ pub fn ffmpeg_video_gif_preview<I: AsRef<Path>, O: AsRef<Path>>(
|
||||
command
|
||||
.arg("-nostdin")
|
||||
.arg("-y")
|
||||
.arg("-loglevel").arg("error")
|
||||
.arg("-nostats")
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-filter_complex_threads").arg("1")
|
||||
.arg("-ss").arg("0")
|
||||
.arg("-to").arg("5")
|
||||
.arg("-i").arg(args.input_video_path.as_ref())
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-filter_complex")
|
||||
.arg("fps=10,scale=360:-1[s]; [s]split[a][b]; [a]palettegen[palette]; [b][palette]paletteuse")
|
||||
.arg("fps=10,scale=360:360:force_original_aspect_ratio=decrease[s]; [s]split[a][b]; [a]palettegen[palette]; [b][palette]paletteuse")
|
||||
.arg("-frames:v").arg("50")
|
||||
.arg(args.output_gif_path.as_ref());
|
||||
|
||||
info!("Calling ffmpeg (gif preview)...");
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
error!("bad exit status: {}", output.status);
|
||||
|
||||
let _r = std::io::stdout().write_all(&output.stdout);
|
||||
let _r = std::io::stderr().write_all(&output.stderr);
|
||||
|
||||
bail!("ffmpeg gif preview failed: {:?}", output.status.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
run_thumbnail_ffmpeg(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -69,7 +64,7 @@ mod tests {
|
||||
|
||||
assert!(output_path.exists(), "output gif should exist");
|
||||
|
||||
// The input video is 640x480. scale=360:-1 should produce 360x270
|
||||
// The input video is 640x480. Fitting within 360x360 should produce 360x270
|
||||
// (maintaining 4:3 aspect ratio).
|
||||
// Use ffprobe to verify the GIF dimensions.
|
||||
let result = ffprobe::ffprobe(&output_path)
|
||||
@@ -82,4 +77,30 @@ mod tests {
|
||||
assert_eq!(video_stream.width, Some(360));
|
||||
assert_eq!(video_stream.height, Some(270));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tall_video_preview_has_bounded_dimensions_and_frame_count() {
|
||||
let temp_dir = TempDir::new_in("/tmp", "ffmpeg_tall_gif_test").unwrap();
|
||||
let input = temp_dir.path().join("tall.mp4");
|
||||
let output = temp_dir.path().join("preview.gif");
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(["-nostdin", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=32x640:rate=10", "-t", "6", "-threads", "1"])
|
||||
.arg(&input)
|
||||
.status().unwrap();
|
||||
assert!(status.success());
|
||||
|
||||
ffmpeg_video_gif_preview(FfmpegVideoGifPreviewArgs {
|
||||
input_video_path: &input,
|
||||
output_gif_path: &output,
|
||||
}).unwrap();
|
||||
let probe = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=width,height,nb_read_frames", "-of", "csv=p=0"])
|
||||
.arg(&output)
|
||||
.output().unwrap();
|
||||
assert!(probe.status.success());
|
||||
let values: Vec<u32> = std::str::from_utf8(&probe.stdout).unwrap().trim()
|
||||
.split(',').map(|value| value.parse().unwrap()).collect();
|
||||
assert_eq!(&values[..2], &[18, 360]);
|
||||
assert!(values[2] > 0 && values[2] <= 50);
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@ pub mod ffmpeg_transcode_to_mp4;
|
||||
pub mod ffmpeg_trim_and_resample;
|
||||
pub mod ffmpeg_video_first_frame_to_jpg_thumbnail;
|
||||
pub mod ffmpeg_video_gif_preview;
|
||||
mod run_thumbnail_ffmpeg;
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::bail;
|
||||
use log::error;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
const THUMBNAIL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const MAX_ERROR_LOG_BYTES: u64 = 16 * 1024;
|
||||
|
||||
/// Synchronous: callers in async runtimes must use spawn_blocking. A timeout
|
||||
/// kills and reaps FFmpeg before returning, so it cannot outlive its job slot.
|
||||
pub(super) fn run_thumbnail_ffmpeg(mut command: Command) -> AnyhowResult<()> {
|
||||
// Avoid holding arbitrarily large FFmpeg stderr output in the Rust heap.
|
||||
let mut stderr = tempfile::tempfile()?;
|
||||
let mut child = command.stdout(Stdio::null()).stderr(Stdio::from(stderr.try_clone()?)).spawn()?;
|
||||
let result = wait_for_exit(&mut child, THUMBNAIL_TIMEOUT);
|
||||
if !matches!(&result, Ok(status) if status.success()) {
|
||||
let start = stderr.metadata()?.len().saturating_sub(MAX_ERROR_LOG_BYTES);
|
||||
stderr.seek(SeekFrom::Start(start))?;
|
||||
let mut bytes = Vec::new();
|
||||
stderr.take(MAX_ERROR_LOG_BYTES).read_to_end(&mut bytes)?;
|
||||
error!("Thumbnail FFmpeg failed: {:?}; stderr: {}", result, String::from_utf8_lossy(&bytes));
|
||||
}
|
||||
let status = result?;
|
||||
if !status.success() {
|
||||
bail!("thumbnail FFmpeg failed: {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_exit(child: &mut Child, timeout: Duration) -> AnyhowResult<ExitStatus> {
|
||||
let started_at = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Ok(status),
|
||||
Ok(None) => {},
|
||||
Err(err) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
if started_at.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
child.wait()?;
|
||||
bail!("thumbnail FFmpeg exceeded timeout of {} seconds", timeout.as_secs_f64());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn timed_out_child_is_killed_and_reaped_before_returning() {
|
||||
let mut child = Command::new("sleep").arg("10").spawn().unwrap();
|
||||
let started_at = Instant::now();
|
||||
let result = wait_for_exit(&mut child, Duration::from_millis(50));
|
||||
assert!(result.unwrap_err().to_string().contains("exceeded timeout"));
|
||||
assert!(started_at.elapsed() < Duration::from_secs(3));
|
||||
assert!(!child.try_wait().unwrap().unwrap().success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_normal_child_exit_status() {
|
||||
let mut child = Command::new("true").spawn().unwrap();
|
||||
assert!(wait_for_exit(&mut child, Duration::from_secs(3)).unwrap().success());
|
||||
}
|
||||
}
|
||||
@@ -56,24 +56,72 @@ schema metadata. For **new tables**, you may need to:
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- Do not give application-owned state/status/type columns schema defaults. Keep required
|
||||
columns `NOT NULL` without a `DEFAULT`; application code must explicitly bind their
|
||||
initial value on every insert. Keep migrations and materialized schema docs in sync.
|
||||
|
||||
### Database enum values
|
||||
|
||||
- Use the Rust enums in `enums::by_table` for database state, status, failure, and type
|
||||
values. Do not hardcode their string representations in SQL or Rust business logic.
|
||||
- Bind enum values (or their `to_str()` representation) through SQL parameters, including
|
||||
`SET`, `WHERE`, `IN`, and `INSERT` values. For example, bind
|
||||
`UserLoginChallengeStatus::Approved.to_str()` to `status = ?`.
|
||||
- Query args and returned rows must use the enum type rather than `String`/`&str` for
|
||||
enum columns. Use SQLx column type overrides and the enum's MySQL decoder on reads;
|
||||
use exhaustive variant matches in application logic. Preserve existing stored values.
|
||||
|
||||
### Error Types
|
||||
|
||||
New queries should return `Result<T, sqlx::Error>` instead of the legacy `AnyhowResult<T>`.
|
||||
The `AnyhowResult` pattern is being phased out.
|
||||
|
||||
### Transaction Support
|
||||
### Named arguments and generic transactors
|
||||
|
||||
Use the executor-generic `Args` pattern for queries that need to work inside transactions:
|
||||
Every new or refactored query must take a single named `<QueryName>Args` struct containing
|
||||
all inputs, including `mysql_executor`. Do not use positional parameter lists, even for
|
||||
small queries. Keep the args struct in the same file as its query.
|
||||
|
||||
Use a generic transactor `T` bounded by `Executor<'c, Database = MySql>`. Do not constrain
|
||||
query functions to `MySqlConnection`, `MySqlPool`, a concrete transaction, or the legacy
|
||||
`Transactor` enum. This applies to all queries, not only ones currently used in transactions.
|
||||
Put the executor bound on the function; the args struct need not carry executor lifetimes
|
||||
or `PhantomData` when it simply stores `T`.
|
||||
|
||||
```rust
|
||||
pub struct MyQueryArgs<'a, 'c: 'a, E>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct MyQueryArgs<'a, T> {
|
||||
pub field: &'a str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn my_query<'c, T>(args: MyQueryArgs<'_, T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!("UPDATE my_table SET name = ?", args.field)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
This allows the same query function to accept both `&MySqlPool` and `&mut Transaction`.
|
||||
Call with `mysql_executor: &pool` for a pool, or `mysql_executor: &mut *tx` /
|
||||
`mysql_executor: &mut *conn` to reborrow the connection held by an owned SQLx transaction /
|
||||
pool connection. SQLx transactions do not themselves implement `Executor`; dereference
|
||||
them at the call site. Reuse the caller's transaction when atomicity or row locks matter.
|
||||
Do not acquire a new connection or begin/commit a transaction inside a query helper.
|
||||
|
||||
### One query per file, grouped by table
|
||||
|
||||
- Put each query in its own descriptively named `.rs` file under the table's directory
|
||||
module, e.g. `queries/user_login_challenges/decide_challenge.rs` or the existing
|
||||
`queries/users/user_sessions/find_redeemed_session.rs`.
|
||||
- Each query file contains one SQL statement/query function, its args struct, and any
|
||||
result type used only by that query. Put shared result types in a separate type file.
|
||||
- Keep `mod.rs` for module declarations; do not collect SQL in it or in catch-all files
|
||||
such as `challenge_queries.rs` or `session_queries.rs`.
|
||||
- Split multi-query operations into individual query files. Keep transaction sequencing
|
||||
and business decisions in the caller/service layer, with every statement using the
|
||||
same transaction when required.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use super::user_media_file_filters::UserMediaFileFilters;
|
||||
|
||||
pub struct CountMediaFilesForUserArgs<'a, T> {
|
||||
pub filters: UserMediaFileFilters<'a>,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Legacy numbered pagination needs an exact total. Count only this user's rows,
|
||||
/// without related-table lookups or global low-selectivity index intersections.
|
||||
pub async fn count_media_files_for_user<'c, T>(args: CountMediaFilesForUserArgs<'_, T>) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let mut query =
|
||||
QueryBuilder::<MySql>::new("SELECT COUNT(*) FROM media_files m FORCE INDEX (fk_maybe_creator_user_token)");
|
||||
args.filters.push_predicates(&mut query);
|
||||
query.build_query_scalar().fetch_one(args.mysql_executor).await
|
||||
}
|
||||
+49
-438
@@ -1,191 +1,10 @@
|
||||
use std::collections::HashSet;
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
|
||||
use super::user_media_file_filters::UserMediaFileFilters;
|
||||
use super::user_media_file_list_item::{MediaFileListItem, MediaFileListItemInternal};
|
||||
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
|
||||
|
||||
pub struct MediaFileListPage {
|
||||
pub records: Vec<MediaFileListItem>,
|
||||
|
||||
pub sort_ascending: bool,
|
||||
|
||||
pub current_page: usize,
|
||||
pub total_page_count: usize,
|
||||
}
|
||||
|
||||
pub struct MediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
pub maybe_origin_model_title: Option<String>,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub is_user_upload: bool,
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
pub maybe_prompt_args: Option<PromptInnerPayload>,
|
||||
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListMediaFileForUserArgs<'a> {
|
||||
pub username: &'a str,
|
||||
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
|
||||
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
|
||||
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
|
||||
pub include_user_uploads: bool,
|
||||
pub page_size: usize,
|
||||
pub page_index: usize,
|
||||
pub sort_ascending: bool,
|
||||
pub view_as: ViewAs,
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> AnyhowResult<MediaFileListPage> {
|
||||
/// Let's figure out how many results we could have returned total
|
||||
let count_fields = select_total_count_field();
|
||||
let mut count_query_builder = query_builder(
|
||||
args.maybe_filter_media_types,
|
||||
args.maybe_filter_media_classes,
|
||||
args.maybe_filter_engine_categories,
|
||||
args.include_user_uploads,
|
||||
args.username,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
count_fields.as_str(),
|
||||
true,
|
||||
);
|
||||
|
||||
let row_count_query = count_query_builder.build_query_scalar::<i64>();
|
||||
let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
|
||||
|
||||
/// Now fetch the actual results with all the fields
|
||||
let result_fields = select_result_fields();
|
||||
let mut query = query_builder(
|
||||
args.maybe_filter_media_types,
|
||||
args.maybe_filter_media_classes,
|
||||
args.maybe_filter_engine_categories,
|
||||
args.include_user_uploads,
|
||||
args.username,
|
||||
true,
|
||||
args.page_index,
|
||||
args.page_size,
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
result_fields.as_str(),
|
||||
false,
|
||||
);
|
||||
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|record| {
|
||||
MediaFileListItem {
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
maybe_origin_model_title: record.maybe_origin_model_title,
|
||||
public_bucket_directory_hash: record.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: i8_to_bool(record.is_user_upload),
|
||||
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
|
||||
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
|
||||
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
|
||||
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_prompt_args: record.maybe_other_prompt_args
|
||||
.as_deref()
|
||||
.map(|args| PromptInnerPayload::from_json(args))
|
||||
.transpose()
|
||||
.ok() // NB: Fail open
|
||||
.flatten(),
|
||||
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
|
||||
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: record.maybe_bookmark_count,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(MediaFileListPage {
|
||||
records: results,
|
||||
sort_ascending: args.sort_ascending,
|
||||
current_page: args.page_index,
|
||||
total_page_count: number_of_pages,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_result_fields() -> String {
|
||||
r#"
|
||||
const RESULT_FIELDS: &str = r#"
|
||||
m.id,
|
||||
m.token,
|
||||
|
||||
@@ -229,258 +48,50 @@ fn select_result_fields() -> String {
|
||||
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
"#
|
||||
.to_string()
|
||||
"#;
|
||||
|
||||
pub struct ListMediaFileForUserArgs<'a, T> {
|
||||
pub filters: UserMediaFileFilters<'a>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
pub sort_ascending: bool,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
fn select_total_count_field() -> String {
|
||||
r#"
|
||||
COUNT(m.id) AS total_count
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn query_builder<'a>(
|
||||
maybe_filter_media_types: Option<&HashSet<MediaFileType>>,
|
||||
maybe_filter_media_classes: Option<&HashSet<MediaFileClass>>,
|
||||
maybe_filter_engine_categories: Option<&HashSet<MediaFileEngineCategory>>,
|
||||
include_user_uploads: bool,
|
||||
username: &'a str,
|
||||
enforce_limits: bool,
|
||||
page_index: usize,
|
||||
page_size: usize,
|
||||
sort_ascending: bool,
|
||||
view_as: ViewAs,
|
||||
select_fields: &'a str,
|
||||
count_only: bool,
|
||||
) -> QueryBuilder<'a, MySql> {
|
||||
|
||||
// NB: The count query only needs the `users` join (for the username predicate).
|
||||
// The other joins are all 1:1 and contribute nothing to the count — MySQL does
|
||||
// not eliminate LEFT JOINs, so including them costs four index lookups per row.
|
||||
let from_clause = if count_only {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
"#
|
||||
} else {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
LEFT OUTER JOIN model_weights as w
|
||||
ON m.maybe_origin_model_token = w.token
|
||||
LEFT OUTER JOIN media_files as media_file_cover_image
|
||||
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
LEFT OUTER JOIN entity_stats
|
||||
ON entity_stats.entity_type = "media_file"
|
||||
AND entity_stats.entity_token = m.token
|
||||
LEFT OUTER JOIN prompts
|
||||
ON prompts.token = m.maybe_prompt_token
|
||||
"#
|
||||
};
|
||||
|
||||
// NB: Query cannot be statically checked by sqlx
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
|
||||
format!(r#"
|
||||
SELECT
|
||||
{select_fields}
|
||||
{from_clause}"#
|
||||
));
|
||||
|
||||
query_builder.push(" WHERE u.username = ");
|
||||
query_builder.push_bind(username);
|
||||
|
||||
//if let Some(media_type) = maybe_filter_media_type {
|
||||
// // FIXME: Binding shouldn't require to_str().
|
||||
// // Otherwise, it's calling the Display trait on the raw type which is resulting in an
|
||||
// // incorrect binding and runtime error.
|
||||
// query_builder.push(" AND m.media_type = ");
|
||||
// query_builder.push_bind(media_type.to_str());
|
||||
//}
|
||||
|
||||
if let Some(media_types) = maybe_filter_media_types {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !media_types.is_empty() {
|
||||
query_builder.push(" AND m.media_type IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for media_type in media_types.iter() {
|
||||
separated.push_bind(media_type.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(media_classes) = maybe_filter_media_classes {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !media_classes.is_empty() {
|
||||
query_builder.push(" AND m.media_class IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for media_class in media_classes.iter() {
|
||||
separated.push_bind(media_class.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(engine_categories) = maybe_filter_engine_categories {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !engine_categories.is_empty() {
|
||||
query_builder.push(" AND m.maybe_engine_category IN ( ");
|
||||
|
||||
let mut separated = query_builder.separated(", ");
|
||||
|
||||
for engine_category in engine_categories.iter() {
|
||||
separated.push_bind(engine_category.to_str());
|
||||
}
|
||||
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if !include_user_uploads {
|
||||
query_builder.push(" AND NOT m.is_user_upload ");
|
||||
}
|
||||
|
||||
// TODO: Argument to control
|
||||
query_builder.push(" AND NOT m.is_intermediate_system_file ");
|
||||
|
||||
match view_as {
|
||||
ViewAs::Author | ViewAs::Moderator => {
|
||||
// NB(bt): Actually, mods don't want to see deleted files. We'll improve the moderator UI later.
|
||||
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
|
||||
}
|
||||
ViewAs::AnotherUser => {
|
||||
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
|
||||
// FIXME: Binding shouldn't require to_str().
|
||||
// Otherwise, it's calling the Display trait on the raw type which is resulting in an
|
||||
// incorrect binding and runtime error.
|
||||
query_builder.push(" AND m.creator_set_visibility = ");
|
||||
query_builder.push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Ordering is meaningless for the single-row COUNT aggregate.
|
||||
if !count_only {
|
||||
if sort_ascending {
|
||||
query_builder.push(" ORDER BY m.created_at ASC ");
|
||||
} else {
|
||||
query_builder.push(" ORDER BY m.created_at DESC ");
|
||||
}
|
||||
}
|
||||
|
||||
if enforce_limits {
|
||||
let offset = page_index * page_size;
|
||||
query_builder.push(format!(" LIMIT {page_size} OFFSET {offset} "));
|
||||
}
|
||||
|
||||
query_builder
|
||||
}
|
||||
|
||||
struct MediaFileListItemInternal {
|
||||
id: i64,
|
||||
token: MediaFileToken,
|
||||
|
||||
media_class: MediaFileClass,
|
||||
media_type: MediaFileType,
|
||||
|
||||
maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
origin_category: MediaFileOriginCategory,
|
||||
origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
maybe_origin_model_title: Option<String>,
|
||||
maybe_other_prompt_args: Option<String>,
|
||||
maybe_duration_millis: Option<i32>,
|
||||
|
||||
public_bucket_directory_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
is_user_upload: i8,
|
||||
is_intermediate_system_file: i8,
|
||||
|
||||
maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
maybe_text_transcript: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
|
||||
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
|
||||
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
|
||||
// cannot have type hints, eg. the following:
|
||||
//
|
||||
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
|
||||
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
//
|
||||
// This results in the automatic mapping not being able to be found by name (for macro derive), and
|
||||
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
|
||||
// full "as" clause).
|
||||
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: MediaFileToken::new(row.try_get("token")?),
|
||||
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
|
||||
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
|
||||
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
|
||||
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
|
||||
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
|
||||
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
|
||||
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
|
||||
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
|
||||
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
|
||||
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
|
||||
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
|
||||
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
is_user_upload: row.try_get("is_user_upload")?,
|
||||
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
|
||||
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
|
||||
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
|
||||
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
|
||||
maybe_title: row.try_get("maybe_title")?,
|
||||
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
/// Select a bounded page before joining potentially large prompt/metadata rows.
|
||||
/// `LIMIT` prevents MySQL from merging the derived table into the outer query.
|
||||
pub async fn list_media_files_for_user<'c, T>(
|
||||
args: ListMediaFileForUserArgs<'_, T>,
|
||||
) -> Result<Vec<MediaFileListItem>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let order = if args.sort_ascending { " ASC" } else { " DESC" };
|
||||
let mut query = QueryBuilder::<MySql>::new(format!("SELECT {RESULT_FIELDS} FROM (SELECT m.id "));
|
||||
// This index is in the July 2026 migrations. Restricting the scan to this user
|
||||
// avoids index-merge scans of the global deletion/class indexes on large tables.
|
||||
query.push("FROM media_files m FORCE INDEX (idx_creator_created_at) ");
|
||||
args.filters.push_predicates(&mut query);
|
||||
query.push(" ORDER BY m.created_at").push(order).push(", m.id").push(order);
|
||||
query.push(" LIMIT ").push_bind(args.limit as u64);
|
||||
query.push(" OFFSET ").push_bind(args.offset as u64);
|
||||
query.push(
|
||||
r#") AS page
|
||||
JOIN media_files m ON m.id = page.id
|
||||
LEFT JOIN model_weights w ON m.maybe_origin_model_token = w.token
|
||||
LEFT JOIN media_files media_file_cover_image ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
LEFT JOIN entity_stats ON entity_stats.entity_type = "#,
|
||||
);
|
||||
query.push_bind(StatsEntityType::MediaFile.to_str());
|
||||
query
|
||||
.push(
|
||||
r#" AND entity_stats.entity_token = m.token
|
||||
LEFT JOIN prompts ON prompts.token = m.maybe_prompt_token
|
||||
ORDER BY m.created_at"#,
|
||||
)
|
||||
.push(order)
|
||||
.push(", m.id")
|
||||
.push(order);
|
||||
let records = query.build_query_as::<MediaFileListItemInternal>().fetch_all(args.mysql_executor).await?;
|
||||
Ok(records.into_iter().map(MediaFileListItem::from).collect())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod count_media_files_for_user;
|
||||
pub mod list_batch_generated_redux_media_files;
|
||||
pub mod list_featured_media_files;
|
||||
pub mod list_media_files;
|
||||
@@ -7,3 +8,5 @@ pub mod list_media_files_for_user;
|
||||
pub mod list_session_media_files_by_class;
|
||||
pub mod list_session_project_media_files;
|
||||
pub mod media_file_list_row;
|
||||
pub mod user_media_file_filters;
|
||||
pub mod user_media_file_list_item;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use sqlx::{MySql, QueryBuilder};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
|
||||
/// Shared predicates keep the optional total and the returned page consistent.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct UserMediaFileFilters<'a> {
|
||||
pub username: &'a str,
|
||||
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
|
||||
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
|
||||
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
|
||||
pub include_user_uploads: bool,
|
||||
pub view_as: ViewAs,
|
||||
}
|
||||
|
||||
impl<'a> UserMediaFileFilters<'a> {
|
||||
pub(super) fn push_predicates(&self, query: &mut QueryBuilder<'a, MySql>) {
|
||||
// Resolve the unique username once; subsequent scans are scoped to its token.
|
||||
query.push(" WHERE m.maybe_creator_user_token = (SELECT token FROM users WHERE username = ");
|
||||
query.push_bind(self.username).push(")");
|
||||
if let Some(values) = self.maybe_filter_media_types.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.media_type IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if let Some(values) = self.maybe_filter_media_classes.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.media_class IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if let Some(values) = self.maybe_filter_engine_categories.filter(|values| !values.is_empty()) {
|
||||
query.push(" AND m.maybe_engine_category IN (");
|
||||
let mut separated = query.separated(", ");
|
||||
for value in values {
|
||||
separated.push_bind(value.to_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
}
|
||||
if !self.include_user_uploads {
|
||||
query.push(" AND NOT m.is_user_upload");
|
||||
}
|
||||
query.push(" AND NOT m.is_intermediate_system_file AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL");
|
||||
if self.view_as == ViewAs::AnotherUser {
|
||||
query.push(" AND m.creator_set_visibility = ").push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
|
||||
|
||||
pub struct MediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
pub maybe_origin_model_title: Option<String>,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub is_user_upload: bool,
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
pub maybe_prompt_args: Option<PromptInnerPayload>,
|
||||
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub(super) struct MediaFileListItemInternal {
|
||||
id: i64,
|
||||
token: MediaFileToken,
|
||||
|
||||
media_class: MediaFileClass,
|
||||
media_type: MediaFileType,
|
||||
|
||||
maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
origin_category: MediaFileOriginCategory,
|
||||
origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
maybe_origin_model_token: Option<String>,
|
||||
|
||||
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
|
||||
maybe_origin_model_title: Option<String>,
|
||||
maybe_other_prompt_args: Option<String>,
|
||||
maybe_duration_millis: Option<i32>,
|
||||
|
||||
public_bucket_directory_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
is_user_upload: i8,
|
||||
is_intermediate_system_file: i8,
|
||||
|
||||
maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
maybe_text_transcript: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
|
||||
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
|
||||
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
|
||||
// cannot have type hints, eg. the following:
|
||||
//
|
||||
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
|
||||
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
//
|
||||
// This results in the automatic mapping not being able to be found by name (for macro derive), and
|
||||
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
|
||||
// full "as" clause).
|
||||
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: MediaFileToken::new(row.try_get("token")?),
|
||||
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
|
||||
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
|
||||
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
|
||||
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
|
||||
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
|
||||
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
|
||||
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
|
||||
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
|
||||
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
|
||||
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
|
||||
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
|
||||
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
is_user_upload: row.try_get("is_user_upload")?,
|
||||
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
|
||||
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
|
||||
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
|
||||
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
|
||||
maybe_title: row.try_get("maybe_title")?,
|
||||
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MediaFileListItemInternal> for MediaFileListItem {
|
||||
fn from(record: MediaFileListItemInternal) -> Self {
|
||||
MediaFileListItem {
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
maybe_origin_model_token: record.maybe_origin_model_token,
|
||||
maybe_origin_model_title: record.maybe_origin_model_title,
|
||||
public_bucket_directory_hash: record.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: i8_to_bool(record.is_user_upload),
|
||||
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
|
||||
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
|
||||
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
|
||||
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
|
||||
maybe_title: record.maybe_title,
|
||||
maybe_text_transcript: record.maybe_text_transcript,
|
||||
maybe_prompt_args: record.maybe_other_prompt_args
|
||||
.as_deref()
|
||||
.map(|args| PromptInnerPayload::from_json(args))
|
||||
.transpose()
|
||||
.ok() // NB: Fail open
|
||||
.flatten(),
|
||||
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
|
||||
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: record.maybe_bookmark_count,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-15
@@ -27,6 +27,11 @@ pub struct VideoMediaFileWithoutThumbnail {
|
||||
pub id: i64,
|
||||
pub token: MediaFileToken,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Database statement-start time, shared by every row in this page.
|
||||
pub database_read_at: DateTime<Utc>,
|
||||
/// Our job's successful finalization time, not the provider's completion time.
|
||||
/// Uploads, legacy records, and jobs still being finalized can have no timestamp.
|
||||
pub maybe_generation_completed_at: Option<DateTime<Utc>>,
|
||||
pub maybe_thumbnail_version: Option<u8>,
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
@@ -51,22 +56,25 @@ pub async fn list_video_media_files_without_thumbnails_for_job<'e, 'c, E>(
|
||||
VideoMediaFileWithoutThumbnail,
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
token as `token: MediaFileToken`,
|
||||
created_at as `created_at: DateTime<Utc>`,
|
||||
maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
|
||||
public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension
|
||||
FROM media_files
|
||||
media.id,
|
||||
media.token as `token: MediaFileToken`,
|
||||
media.created_at as `created_at: DateTime<Utc>`,
|
||||
NOW(6) as `database_read_at!: DateTime<Utc>`,
|
||||
jobs.successfully_completed_at as `maybe_generation_completed_at?: DateTime<Utc>`,
|
||||
media.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
|
||||
media.public_bucket_directory_hash,
|
||||
media.maybe_public_bucket_prefix,
|
||||
media.maybe_public_bucket_extension
|
||||
FROM media_files AS media
|
||||
LEFT JOIN generic_inference_jobs AS jobs ON jobs.token = media.maybe_source_job_token
|
||||
WHERE
|
||||
id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)
|
||||
AND id < ?
|
||||
AND media_class = ?
|
||||
AND maybe_thumbnail_version IS NULL
|
||||
AND user_deleted_at IS NULL
|
||||
AND mod_deleted_at IS NULL
|
||||
ORDER BY id DESC
|
||||
media.id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)
|
||||
AND media.id < ?
|
||||
AND media.media_class = ?
|
||||
AND media.maybe_thumbnail_version IS NULL
|
||||
AND media.user_deleted_at IS NULL
|
||||
AND media.mod_deleted_at IS NULL
|
||||
ORDER BY media.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
max_lookback_hours,
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod unsubscribe_reason;
|
||||
pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_referrals;
|
||||
pub mod user_daily_spends;
|
||||
@@ -52,4 +53,4 @@ pub mod voice_conversion;
|
||||
pub mod voice_designer;
|
||||
pub mod wallet_ledger_entries;
|
||||
pub mod wallets;
|
||||
pub mod web_referrals;
|
||||
pub mod web_referrals;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
|
||||
pub struct CreateChallengeArgs<'a, T> {
|
||||
pub token: &'a UserLoginChallengeToken,
|
||||
pub approval_hash: &'a [u8],
|
||||
pub device_hash: &'a [u8],
|
||||
pub confirmation_code: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn create_challenge<'c, T>(args: CreateChallengeArgs<'_, T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_login_challenges
|
||||
(token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
args.approval_hash,
|
||||
args.device_hash,
|
||||
args.confirmation_code,
|
||||
args.ip_address,
|
||||
args.status.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct DecideChallengeArgs<'a, T> {
|
||||
pub challenge_id: u64,
|
||||
pub user_token: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub approve: bool,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn decide_challenge<'c, T>(args: DecideChallengeArgs<'_, T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let status = if args.approve { UserLoginChallengeStatus::Approved } else { UserLoginChallengeStatus::Failed };
|
||||
let failure = if args.approve { None } else { Some(UserLoginChallengeFailureType::UserDeclined) };
|
||||
let failure_ip = if args.approve { None } else { Some(args.ip_address) };
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,
|
||||
maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,
|
||||
maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())
|
||||
WHERE id = ? AND status = ? AND expires_at > NOW()
|
||||
"#,
|
||||
status.to_str(),
|
||||
args.user_token,
|
||||
args.ip_address,
|
||||
failure.map(|value| value.to_str()),
|
||||
failure_ip,
|
||||
args.approve,
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Pending.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct ExpireAbandonedChallengesArgs<T> {
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Bounded sweep also audits abandoned attempts that are never polled again.
|
||||
pub async fn expire_abandoned_challenges<'c, T>(args: ExpireAbandonedChallengesArgs<T>) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at
|
||||
WHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000
|
||||
"#,
|
||||
UserLoginChallengeStatus::Failed.to_str(),
|
||||
UserLoginChallengeFailureType::Expired.to_str(),
|
||||
UserLoginChallengeStatus::Pending.to_str(),
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct ExpireChallengeArgs<T> {
|
||||
pub challenge_id: u64,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Audit expiration of a pending/approved challenge in the caller's transaction.
|
||||
pub async fn expire_challenge<'c, T>(args: ExpireChallengeArgs<T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at
|
||||
WHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()
|
||||
"#,
|
||||
UserLoginChallengeStatus::Failed.to_str(),
|
||||
UserLoginChallengeFailureType::Expired.to_str(),
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Pending.to_str(),
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct IsChallengeExpiredArgs<T> {
|
||||
pub challenge_id: u64,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Run AFTER acquiring the row lock: NOW() on the locking statement can precede
|
||||
/// time spent waiting for another transaction. Never extend the original deadline.
|
||||
pub async fn is_challenge_expired<'c, T>(args: IsChallengeExpiredArgs<T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?
|
||||
"#,
|
||||
args.challenge_id
|
||||
)
|
||||
.fetch_one(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(result.expired != 0)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::login_challenge::LoginChallenge;
|
||||
|
||||
pub struct LockByApprovalArgs<'a, T> {
|
||||
pub approval_hash: &'a [u8],
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Use the caller's transaction to retain the row lock for subsequent decisions.
|
||||
/// The approval token cannot be used in device lookups.
|
||||
pub async fn lock_by_approval<'c, T>(args: LockByApprovalArgs<'_, T>) -> Result<Option<LoginChallenge>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
args.approval_hash
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::login_challenge::LoginChallenge;
|
||||
|
||||
pub struct LockByDeviceArgs<'a, T> {
|
||||
pub device_hash: &'a [u8],
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Use the caller's transaction to retain the row lock throughout redemption.
|
||||
pub async fn lock_by_device<'c, T>(args: LockByDeviceArgs<'_, T>) -> Result<Option<LoginChallenge>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
args.device_hash
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
|
||||
pub struct LoginChallenge {
|
||||
pub id: u64,
|
||||
pub confirmation_code: String,
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub maybe_deciding_user_token: Option<String>,
|
||||
pub maybe_redeemed_user_session_id: Option<i64>,
|
||||
pub maybe_failure_type: Option<UserLoginChallengeFailureType>,
|
||||
pub ip_address_creation: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct MarkRedeemedArgs<'a, T> {
|
||||
pub challenge_id: u64,
|
||||
pub session_id: i64,
|
||||
pub ip_address: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn mark_redeemed<'c, T>(args: MarkRedeemedArgs<'_, T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,
|
||||
maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()
|
||||
WHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL
|
||||
"#,
|
||||
UserLoginChallengeStatus::Redeemed.to_str(),
|
||||
args.session_id,
|
||||
args.ip_address,
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod create_challenge;
|
||||
pub mod decide_challenge;
|
||||
pub mod expire_abandoned_challenges;
|
||||
pub mod expire_challenge;
|
||||
pub mod is_challenge_expired;
|
||||
pub mod lock_by_approval;
|
||||
pub mod lock_by_device;
|
||||
pub mod login_challenge;
|
||||
pub mod mark_redeemed;
|
||||
@@ -0,0 +1,6 @@
|
||||
pub struct BridgeSession {
|
||||
pub id: i64,
|
||||
pub token: String,
|
||||
pub user_token: String,
|
||||
pub username: String,
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
|
||||
pub struct CreateBridgeSessionArgs<'a, T> {
|
||||
pub user_token: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Returns the inserted session ID, or None if the user cannot sign in.
|
||||
/// Must share the transaction holding the challenge lock. A failed/expired
|
||||
/// redemption rolls back this INSERT; no session is minted when merely approving.
|
||||
pub async fn create_bridge_session<'c, T>(args: CreateBridgeSessionArgs<'_, T>) -> Result<Option<i64>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = UserSessionToken::generate();
|
||||
let inserted = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)
|
||||
SELECT ?, token, ?, ?, NOW() + INTERVAL 1 YEAR FROM users
|
||||
WHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.ip_address,
|
||||
UserWebSessionCreationType::DeviceApproval.to_str(),
|
||||
args.user_token
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
if inserted.rows_affected() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(inserted.last_insert_id() as i64))
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
@@ -36,14 +37,16 @@ INSERT INTO user_sessions (
|
||||
user_token,
|
||||
maybe_impersonation_user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
args.user_token.as_str(),
|
||||
args.impersonator_user_token.as_str(),
|
||||
args.ip_address,
|
||||
UserWebSessionCreationType::Impersonation.to_str(),
|
||||
args.expires_at,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@@ -17,13 +18,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token,
|
||||
user_token.to_string(),
|
||||
ip_address.to_string(),
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
)
|
||||
.execute(mysql_pool)
|
||||
.await;
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
@@ -19,13 +20,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
ip_address,
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
)
|
||||
.execute(mysql_executor)
|
||||
.await?;
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@@ -22,13 +23,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
ip_address,
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
);
|
||||
|
||||
let query_result = transactor.execute(query).await;
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::bridge_session::BridgeSession;
|
||||
|
||||
pub struct FindApprovingSessionArgs<'a, T> {
|
||||
pub session_token: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Fresh database lookup, not a cached session. Impersonated sessions cannot consent.
|
||||
pub async fn find_approving_session<'c, T>(args: FindApprovingSessionArgs<'_, T>) -> Result<Option<BridgeSession>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
BridgeSession,
|
||||
r#"
|
||||
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
|
||||
WHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()
|
||||
AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE
|
||||
AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
|
||||
"#,
|
||||
args.session_token
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user