mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 09:22:41 -04:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de780b0355 | ||
|
|
cb5abeff08 | ||
|
|
78c688e104 | ||
|
|
b3d281a7f5 | ||
|
|
fe0610b779 | ||
|
|
24e2b44207 | ||
|
|
db344608b6 | ||
|
|
146b78dcb6 | ||
|
|
c822872f36 | ||
|
|
6eb85010d6 | ||
|
|
855e169b44 | ||
|
|
ea2f5106df | ||
|
|
0de867ad13 | ||
|
|
a1cb1b2629 |
+1
-1
@@ -120,7 +120,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE owner_user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab"
|
||||
}
|
||||
+2
-2
@@ -210,7 +210,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -220,7 +220,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -50,7 +50,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -60,7 +60,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -210,7 +210,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -220,7 +220,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -50,7 +50,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -190,7 +190,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+3
-3
@@ -190,7 +190,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -200,7 +200,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -210,7 +210,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -60,7 +60,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE api_key = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922"
|
||||
}
|
||||
+1
-1
@@ -150,7 +150,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c"
|
||||
}
|
||||
+1
-1
@@ -160,7 +160,7 @@
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+3
-3
@@ -190,7 +190,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -200,7 +200,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -210,7 +210,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE api_keys\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805"
|
||||
}
|
||||
+2
-2
@@ -180,7 +180,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -190,7 +190,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -180,7 +180,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -190,7 +190,7 @@
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -190,7 +190,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+503
-564
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -7,6 +7,7 @@ members = [
|
||||
# API clients for external services
|
||||
# These might be custom implementations or wrappers over other client libraries.
|
||||
"crates/api_clients/artcraft/artcraft_api_defs",
|
||||
"crates/api_clients/artcraft/artcraft_api_keys",
|
||||
"crates/api_clients/artcraft/artcraft_client",
|
||||
"crates/api_clients/artcraft/artcraft_router",
|
||||
"crates/api_clients/cloud_storage",
|
||||
@@ -232,6 +233,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
|
||||
actix_artcraft = { path = "crates/lib/actix_artcraft" }
|
||||
actix_helpers = { path = "crates/lib/actix_helpers" }
|
||||
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
|
||||
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
|
||||
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
|
||||
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
|
||||
bootstrap = { path = "crates/lib/bootstrap" }
|
||||
@@ -342,11 +344,11 @@ sha2 = "0.11.0-rc.5"
|
||||
sha3 = "0.10.8"
|
||||
speculoos = "0.11.0"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.3.3"
|
||||
tauri-plugin-fs = "2.4.2"
|
||||
tauri-plugin-log = "2.6.0"
|
||||
tauri-plugin-upload = "2.3.1"
|
||||
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-upload = "2.4.0"
|
||||
tempdir = "0.3.7"
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The actual API key value presented by clients to authenticate.
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
|
||||
-- ========== KEY INFO ==========
|
||||
|
||||
-- User-supplied display name for the key.
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Optional user-supplied description.
|
||||
maybe_description VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- ========== OWNER ==========
|
||||
|
||||
-- The user that owns this API key.
|
||||
owner_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_update VARCHAR(40) NOT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the key is live.
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (api_key),
|
||||
KEY fk_owner_user_token (owner_user_token),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The actual API key value presented by clients to authenticate.
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
|
||||
-- ========== KEY INFO ==========
|
||||
|
||||
-- User-supplied display name for the key.
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Optional user-supplied description.
|
||||
maybe_description VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- ========== OWNER ==========
|
||||
|
||||
-- The user that owns this API key.
|
||||
owner_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_update VARCHAR(40) NOT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the key is live.
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (api_key),
|
||||
KEY fk_owner_user_token (owner_user_token),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,42 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Canonical wire shape for an API key, used by the list rows and the
|
||||
/// single-key GET.
|
||||
///
|
||||
/// NB: the full secret `api_key` is NEVER included here — only
|
||||
/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys
|
||||
/// in a UI. The full value is returned exactly once, at creation time.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ApiKeyInfo {
|
||||
pub token: ApiKeyToken,
|
||||
|
||||
/// First 20 characters of the API key. The full secret is never returned
|
||||
/// after creation.
|
||||
pub truncated_api_key: String,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_description: Option<String>,
|
||||
|
||||
pub owner_user_token: UserToken,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
/// Soft-delete timestamp. `None` for live keys.
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Path info for endpoints addressed by the API key's `token` (NOT the secret
|
||||
/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ApiKeyPathInfo {
|
||||
pub api_key_token: ApiKeyToken,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
// ── POST /v1/api_keys/create ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateApiKeyRequest {
|
||||
/// Display name (title) for the key.
|
||||
pub name: String,
|
||||
|
||||
/// Optional description.
|
||||
pub maybe_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CreateApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The key's `token` — used for all subsequent management
|
||||
/// (get / update / delete). Safe to store and display.
|
||||
pub api_key_token: ApiKeyToken,
|
||||
|
||||
/// The full secret API key value. This is the ONLY time it is ever
|
||||
/// returned — the caller must store it now, as it cannot be retrieved again.
|
||||
pub api_key: String,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── DELETE /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DeleteApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::api_keys::common::ApiKeyInfo;
|
||||
|
||||
// ── GET /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct GetApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
pub api_key: ApiKeyInfo,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::api_keys::common::ApiKeyInfo;
|
||||
|
||||
// ── GET /v1/api_keys/list ──
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListApiKeysQueryParams {
|
||||
/// Page size. Defaults server-side if omitted.
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Number of rows to skip. Defaults to 0.
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListApiKeysSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The user's API keys (including soft-deleted ones), newest first. Each
|
||||
/// entry's key value is truncated — the full secret is never listed.
|
||||
pub api_keys: Vec<ApiKeyInfo>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod common;
|
||||
pub mod create_api_key;
|
||||
pub mod delete_api_key;
|
||||
pub mod get_api_key;
|
||||
pub mod list_api_keys;
|
||||
pub mod update_api_key;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── PUT /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateApiKeyRequest {
|
||||
/// New description for the key. `None` clears it.
|
||||
pub maybe_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UpdateApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analytics;
|
||||
pub mod api_keys;
|
||||
pub mod characters;
|
||||
pub mod common;
|
||||
pub mod credits;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "artcraft_api_keys"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "artcraft_api_keys"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Hackari workspace (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::parse_from_header_value::parse_from_header_value;
|
||||
|
||||
pub mod parse_from_header_value;
|
||||
|
||||
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
|
||||
/// the secret is redacted so the full key never lands in logs or error messages.
|
||||
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
|
||||
|
||||
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
|
||||
///
|
||||
/// `Debug` and `Display` both redact the value, printing only the first
|
||||
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
|
||||
/// accidentally logged.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ArtcraftApiKey(pub String);
|
||||
|
||||
impl ArtcraftApiKey {
|
||||
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
|
||||
ArtcraftApiKey(value.to_string())
|
||||
}
|
||||
|
||||
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
|
||||
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
|
||||
/// full parsing rules.
|
||||
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
|
||||
parse_from_header_value(header_value)
|
||||
}
|
||||
|
||||
/// The full, unredacted key value as a borrowed `&str`.
|
||||
///
|
||||
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
|
||||
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
|
||||
/// (storage, the create-key response, tests), never in logs or error messages.
|
||||
pub fn as_str_be_careful(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
|
||||
/// caveat behind the `_be_careful` suffix.
|
||||
pub fn to_string_be_careful(&self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
/// The redacted form used by both `Debug` and `Display`: the first
|
||||
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
|
||||
fn redacted(&self) -> String {
|
||||
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
|
||||
format!("{prefix}…")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ArtcraftApiKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ArtcraftApiKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ArtcraftApiKey({:?})", self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
#[test]
|
||||
fn new_from_str_and_as_str_round_trip() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
|
||||
assert_eq!(key.0, SAMPLE_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_redacts_to_first_twenty_characters() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_to_first_twenty_characters() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_does_not_leak_the_full_secret() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
|
||||
assert!(!format!("{key}").contains(suffix));
|
||||
assert!(!format!("{key:?}").contains(suffix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_than_prefix_is_printed_in_full() {
|
||||
let key = ArtcraftApiKey::new_from_str("abc");
|
||||
assert_eq!(format!("{key}"), "abc…");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use crate::ArtcraftApiKey;
|
||||
|
||||
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
|
||||
const BEARER_SCHEME: &str = "bearer";
|
||||
|
||||
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
|
||||
const KEY_SCHEME: &str = "key";
|
||||
|
||||
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
|
||||
///
|
||||
/// The value is accepted in three forms:
|
||||
///
|
||||
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
|
||||
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
|
||||
/// - `<api_key>` (AWS-style — the bare key, no scheme)
|
||||
///
|
||||
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
|
||||
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
|
||||
/// a scheme with no credential.
|
||||
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
|
||||
let trimmed = header_value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = match trimmed.split_once(char::is_whitespace) {
|
||||
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
|
||||
Some((scheme, rest)) => {
|
||||
if is_supported_scheme(scheme) {
|
||||
let key = rest.trim();
|
||||
if key.is_empty() {
|
||||
// A supported scheme with no credential.
|
||||
return None;
|
||||
}
|
||||
key
|
||||
} else {
|
||||
// An unsupported scheme (e.g. "Basic ..."). A bare API key never
|
||||
// contains whitespace, so a multi-token value we don't recognize is
|
||||
// not a usable key.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
|
||||
// just a bare scheme keyword (a malformed header carrying no credential).
|
||||
None => {
|
||||
if is_supported_scheme(trimmed) {
|
||||
return None;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
|
||||
Some(ArtcraftApiKey::new_from_str(key))
|
||||
}
|
||||
|
||||
fn is_supported_scheme(word: &str) -> bool {
|
||||
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
fn parsed(header_value: &str) -> Option<String> {
|
||||
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_scheme_is_case_insensitive() {
|
||||
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_scheme_is_case_insensitive() {
|
||||
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_key_aws_style() {
|
||||
assert_eq!(parsed("abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrounding_and_inner_whitespace_is_trimmed() {
|
||||
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
|
||||
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
|
||||
assert_eq!(parsed(" abc "), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_blank_is_none() {
|
||||
assert_eq!(parsed(""), None);
|
||||
assert_eq!(parsed(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_with_no_credential_is_none() {
|
||||
assert_eq!(parsed("Bearer"), None);
|
||||
assert_eq!(parsed("Bearer "), None);
|
||||
assert_eq!(parsed("Key"), None);
|
||||
assert_eq!(parsed("key "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_scheme_is_none() {
|
||||
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
|
||||
assert_eq!(parsed("Digest abc"), None);
|
||||
// Two non-scheme tokens — a bare key never contains whitespace.
|
||||
assert_eq!(parsed("abc def"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_an_artcraft_api_key() {
|
||||
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
|
||||
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ name = "artcraft_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.1.1", features = [] }
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
|
||||
@@ -20,6 +20,7 @@ path = "src/lib.rs"
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_api_keys.workspace = true
|
||||
bucket_paths.workspace = true
|
||||
composite_identifiers = { path = "../../../schema/public/composite_identifiers" }
|
||||
enums = { workspace = true, features = ["database"] }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A materialized `api_keys` row, minus the internal `id` and the full secret
|
||||
/// `api_key`. `api_key_prefix` is the first 20 characters of the key — enough to
|
||||
/// disambiguate keys in a UI without ever exposing the secret value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyRow {
|
||||
pub token: ApiKeyToken,
|
||||
|
||||
/// First 20 characters of the `api_key`. The full secret is never returned.
|
||||
pub api_key_prefix: String,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_description: Option<String>,
|
||||
|
||||
pub owner_user_token: UserToken,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
pub struct DeleteApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Soft-delete an API key by stamping `maybe_deleted_at = NOW()`. Returns the
|
||||
/// number of rows affected (0 if no live key matched the token).
|
||||
pub async fn delete_api_key<'e, 'c: 'e, E>(
|
||||
args: DeleteApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE api_keys
|
||||
SET maybe_deleted_at = NOW()
|
||||
WHERE token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct GetApiKeyByTokenArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up an API key by its `token`. Returns every detail (including
|
||||
/// `maybe_deleted_at`, so soft-deleted keys are still visible here) except the
|
||||
/// internal `id` and the full secret `api_key` (only its first 20 characters
|
||||
/// are returned). `Ok(None)` if no row matches the token.
|
||||
pub async fn get_api_key_by_token<'e, 'c: 'e, E>(
|
||||
args: GetApiKeyByTokenArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
}))
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct GetLiveApiKeyByValueArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The actual API key secret presented by the client.
|
||||
pub api_key: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up a **live** API key by its secret value (for authenticating an
|
||||
/// incoming request). Returns `Ok(None)` if no row matches OR if the matching
|
||||
/// row is soft-deleted (`maybe_deleted_at IS NOT NULL`). The full secret is
|
||||
/// never echoed back — only its first 20 characters — and the internal `id` is
|
||||
/// omitted.
|
||||
pub async fn get_live_api_key_by_value<'e, 'c: 'e, E>(
|
||||
args: GetLiveApiKeyByValueArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE api_key = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.api_key,
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct InsertApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
pub ip_address: &'e str,
|
||||
pub name: &'e str,
|
||||
pub maybe_description: Option<&'e str>,
|
||||
pub api_key: &'e ArtcraftApiKey,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Insert a new API key. The `ApiKeyToken` is minted here (in the data-access
|
||||
/// layer, not the handler) and returned to the caller. The provided IP address
|
||||
/// is stamped on both the creation and update IP columns, and `created_at` is
|
||||
/// set to `NOW()`.
|
||||
pub async fn insert_api_key<'e, 'c: 'e, E>(
|
||||
args: InsertApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<ApiKeyToken, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = ApiKeyToken::generate();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO api_keys
|
||||
SET
|
||||
token = ?,
|
||||
api_key = ?,
|
||||
name = ?,
|
||||
maybe_description = ?,
|
||||
owner_user_token = ?,
|
||||
ip_address_creation = ?,
|
||||
ip_address_update = ?,
|
||||
created_at = NOW()
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.api_key.as_str_be_careful(), // NB: This is okay in this case.
|
||||
args.name,
|
||||
args.maybe_description,
|
||||
args.owner_user_token.as_str(),
|
||||
args.ip_address,
|
||||
args.ip_address,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct ListApiKeysForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
|
||||
/// Page size and page offset. Pagination is LIMIT/OFFSET rather than keyset
|
||||
/// so we don't have to expose the internal `id`.
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// List a user's API keys, newest first, **including soft-deleted** keys.
|
||||
/// Never returns the full `api_key` — only its first 20 characters — and never
|
||||
/// returns the internal `id`.
|
||||
pub async fn list_api_keys_for_user<'e, 'c: 'e, E>(
|
||||
args: ListApiKeysForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let limit = args.limit as i64;
|
||||
let offset = args.offset as i64;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE owner_user_token = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.owner_user_token.as_str(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod api_key_row;
|
||||
pub mod delete_api_key;
|
||||
pub mod get_api_key_by_token;
|
||||
pub mod get_live_api_key_by_value;
|
||||
pub mod insert_api_key;
|
||||
pub mod list_api_keys_for_user;
|
||||
pub mod update_api_key;
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
pub struct UpdateApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub maybe_description: Option<&'e str>,
|
||||
|
||||
/// IP address of the request making the update; stamped on `ip_address_update`.
|
||||
pub ip_address: &'e str,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Update an API key's `maybe_description`, also refreshing `ip_address_update`
|
||||
/// (and, via the column's `ON UPDATE` clause, `updated_at`). Scoped to live keys
|
||||
/// only. Returns the number of rows affected (0 if no live key matched).
|
||||
pub async fn update_api_key<'e, 'c: 'e, E>(
|
||||
args: UpdateApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE api_keys
|
||||
SET
|
||||
maybe_description = ?,
|
||||
ip_address_update = ?
|
||||
WHERE token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.maybe_description,
|
||||
args.ip_address,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
pub mod analytics_active_users;
|
||||
pub mod api_keys;
|
||||
pub mod api_tokens;
|
||||
pub mod audit_logs;
|
||||
pub mod batch_generations;
|
||||
|
||||
@@ -23,6 +23,7 @@ use strum::EnumIter;
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
pub(crate) enum TokenPrefix {
|
||||
AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables.
|
||||
ApiKey,
|
||||
AppSession, // NB: These are generated client-side (!)
|
||||
AuditLog,
|
||||
BatchGeneration,
|
||||
@@ -112,6 +113,7 @@ impl PrefixGenerator for TokenPrefix {
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::AnonymousVisitorTracking => "avt_",
|
||||
Self::ApiKey => "api_key_",
|
||||
Self::AppSession => "app_session_",
|
||||
Self::AuditLog => "audit_",
|
||||
Self::BatchGeneration => "batch_g_",
|
||||
@@ -348,6 +350,7 @@ mod tests {
|
||||
|| prefix == "INT_API:"
|
||||
|| prefix == "batch_g_"
|
||||
|| prefix == "beta_key_"
|
||||
|| prefix == "api_key_"
|
||||
|| prefix == "email_job_"
|
||||
|| prefix == "pw_reset_"
|
||||
|| prefix == "tts_task_"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The primary key for api_keys.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct ApiKeyToken(pub String);
|
||||
|
||||
impl_mysql_token_from_row!(ApiKeyToken);
|
||||
impl_string_token!(ApiKeyToken);
|
||||
impl_crockford_generator!(ApiKeyToken, 32usize, TokenPrefix::ApiKey, CrockfordLower);
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod anonymous_visitor_tracking;
|
||||
pub mod api_keys;
|
||||
pub mod api_tokens_external;
|
||||
pub mod api_tokens_internal;
|
||||
pub mod app_session;
|
||||
|
||||
@@ -36,6 +36,7 @@ actix_artcraft.workspace = true
|
||||
actix_cors_configs = { path = "../../../lib/actix_cors_configs" }
|
||||
actix_helpers.workspace = true
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_api_keys.workspace = true
|
||||
artcraft_router.workspace = true
|
||||
bootstrap.workspace = true
|
||||
bucket_paths.workspace = true
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::info;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub async fn connect_to_database() -> AnyhowResult<MySqlPool>{
|
||||
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
|
||||
let db_connection_string = env_get_mysql_connection_string_or_default();
|
||||
|
||||
let pool_options = MySqlPoolOptions::new()
|
||||
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 5)?);
|
||||
// NB: The managed DB's connection ceiling is far above what we use, so the binding
|
||||
// constraint is this per-pod pool. Keep `replicas * MYSQL_MAX_CONNECTIONS` (plus the
|
||||
// background jobs' small pools) comfortably under the server's `max_connections`.
|
||||
let max_connections = easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 20)?;
|
||||
let min_connections = easyenv::get_env_num("MYSQL_MIN_CONNECTIONS", 0)?;
|
||||
|
||||
// TODO(bt,2024-09-17): Configure logging, etc. after we've upgraded sqlx
|
||||
// NB: Fail fast under pool saturation rather than stacking up on sqlx's 30s default — a
|
||||
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
|
||||
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
|
||||
|
||||
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
|
||||
// log the connection string (it contains credentials).
|
||||
info!(
|
||||
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
|
||||
max_connections, min_connections, acquire_timeout_seconds,
|
||||
);
|
||||
|
||||
let pool_options = MySqlPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.min_connections(min_connections)
|
||||
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
|
||||
|
||||
let pool = pool_options
|
||||
.connect(&db_connection_string)
|
||||
|
||||
@@ -95,6 +95,19 @@ use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_ha
|
||||
use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::*;
|
||||
|
||||
// API key endpoints
|
||||
use crate::http_server::endpoints::api_keys::create_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::list_api_keys_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::get_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::delete_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::update_api_key_handler::*;
|
||||
use artcraft_api_defs::api_keys::common::*;
|
||||
use artcraft_api_defs::api_keys::create_api_key::*;
|
||||
use artcraft_api_defs::api_keys::list_api_keys::*;
|
||||
use artcraft_api_defs::api_keys::get_api_key::*;
|
||||
use artcraft_api_defs::api_keys::delete_api_key::*;
|
||||
use artcraft_api_defs::api_keys::update_api_key::*;
|
||||
|
||||
// Folder endpoints
|
||||
use crate::http_server::endpoints::folders::folder::color_code_folder_handler::*;
|
||||
use crate::http_server::endpoints::folders::folder::cover_image_folder_handler::*;
|
||||
@@ -561,6 +574,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::moderator_list_email_address_changes_for_user_handler,
|
||||
crate::http_server::endpoints::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids_handler::moderator_get_user_stripe_customer_ids_handler,
|
||||
|
||||
// API keys
|
||||
crate::http_server::endpoints::api_keys::create_api_key_handler::create_api_key_handler,
|
||||
crate::http_server::endpoints::api_keys::list_api_keys_handler::list_api_keys_handler,
|
||||
crate::http_server::endpoints::api_keys::get_api_key_handler::get_api_key_handler,
|
||||
crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler,
|
||||
crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler,
|
||||
|
||||
// Folders
|
||||
crate::http_server::endpoints::folders::folder::create_folder_handler::create_folder_handler,
|
||||
crate::http_server::endpoints::folders::folder::list_folders_handler::list_folders_handler,
|
||||
@@ -1250,6 +1270,18 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
// User Emails (Moderation)
|
||||
ModeratorListUserEmailChangesQueryParams,
|
||||
|
||||
// API keys
|
||||
ApiKeyInfo,
|
||||
ApiKeyPathInfo,
|
||||
CreateApiKeyRequest,
|
||||
CreateApiKeySuccessResponse,
|
||||
ListApiKeysQueryParams,
|
||||
ListApiKeysSuccessResponse,
|
||||
GetApiKeySuccessResponse,
|
||||
DeleteApiKeySuccessResponse,
|
||||
UpdateApiKeyRequest,
|
||||
UpdateApiKeySuccessResponse,
|
||||
|
||||
// Folders
|
||||
FolderInfo,
|
||||
FolderThumbnail,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyInfo;
|
||||
use mysql_queries::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
/// Convert a data-access `ApiKeyRow` into the public `ApiKeyInfo` wire shape.
|
||||
/// The row only ever carries the truncated key prefix (never the full secret),
|
||||
/// so this is a straight field map.
|
||||
pub fn api_key_row_to_info(row: ApiKeyRow) -> ApiKeyInfo {
|
||||
ApiKeyInfo {
|
||||
token: row.token,
|
||||
truncated_api_key: row.api_key_prefix,
|
||||
name: row.name,
|
||||
maybe_description: row.maybe_description,
|
||||
owner_user_token: row.owner_user_token,
|
||||
ip_address_creation: row.ip_address_creation,
|
||||
ip_address_update: row.ip_address_update,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
maybe_deleted_at: row.maybe_deleted_at,
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::create_api_key::{CreateApiKeyRequest, CreateApiKeySuccessResponse};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::generate_api_key::generate_api_key;
|
||||
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
const MAX_DESCRIPTION_LEN: usize = 512;
|
||||
|
||||
/// Create a new API key for the logged-in user. The full secret `api_key`
|
||||
/// value is returned exactly once in this response — it can never be retrieved
|
||||
/// again afterward.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/create",
|
||||
request_body = CreateApiKeyRequest,
|
||||
responses(
|
||||
(status = 200, body = CreateApiKeySuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn create_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<CreateApiKeyRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<CreateApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let name = request.name.trim().to_string();
|
||||
|
||||
let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string());
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage("name is empty".to_string()));
|
||||
}
|
||||
if name.len() > MAX_NAME_LEN {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("name too long (max {} chars)", MAX_NAME_LEN),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(description) = &maybe_description {
|
||||
if description.len() > MAX_DESCRIPTION_LEN {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("description too long (max {} chars)", MAX_DESCRIPTION_LEN),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// Generate the secret once. It is returned to the caller here and never again.
|
||||
let api_key = generate_api_key();
|
||||
|
||||
let token = insert_api_key(InsertApiKeyArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
ip_address: &ip_address,
|
||||
name: &name,
|
||||
maybe_description: maybe_description.as_deref(),
|
||||
api_key: &api_key,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("insert_api_key failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(CreateApiKeySuccessResponse {
|
||||
success: true,
|
||||
api_key_token: token,
|
||||
// Return the full secret value (never the redacted Debug/Display form). This is the only
|
||||
// time it is ever exposed.
|
||||
api_key: api_key.to_string_be_careful(), // NB: This is okay in this case
|
||||
}))
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
|
||||
use artcraft_api_defs::api_keys::delete_api_key::DeleteApiKeySuccessResponse;
|
||||
use mysql_queries::queries::api_keys::delete_api_key::{delete_api_key, DeleteApiKeyArgs};
|
||||
use mysql_queries::queries::api_keys::get_api_key_by_token::{
|
||||
get_api_key_by_token, GetApiKeyByTokenArgs,
|
||||
};
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Soft-delete an API key by `token`, scoped to the logged-in user.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/{api_key_token}",
|
||||
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
|
||||
responses(
|
||||
(status = 200, body = DeleteApiKeySuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn delete_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ApiKeyPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<DeleteApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
// The by-token delete query isn't owner-scoped, so first look the key up and
|
||||
// confirm ownership. Return NotFound for missing OR other-owned keys, so we
|
||||
// never leak the existence of another user's key.
|
||||
let row = get_api_key_by_token(GetApiKeyByTokenArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_api_key_by_token failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
.filter(|row| row.owner_user_token == user_session.user_token)
|
||||
.ok_or(CommonWebError::NotFound)?;
|
||||
|
||||
// Only issue the delete if the key is still live. If it's already
|
||||
// soft-deleted the desired end state already holds, so treat it as success.
|
||||
if row.maybe_deleted_at.is_none() {
|
||||
delete_api_key(DeleteApiKeyArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("delete_api_key failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(Json(DeleteApiKeySuccessResponse { success: true }))
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
|
||||
use artcraft_api_defs::api_keys::get_api_key::GetApiKeySuccessResponse;
|
||||
use mysql_queries::queries::api_keys::get_api_key_by_token::{
|
||||
get_api_key_by_token, GetApiKeyByTokenArgs,
|
||||
};
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Fetch a single API key by its `token`, scoped to the logged-in user.
|
||||
/// `NotFound` if the key doesn't exist or belongs to a different user (so we
|
||||
/// don't leak the existence of others' keys). Never returns the full secret.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/{api_key_token}",
|
||||
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
|
||||
responses(
|
||||
(status = 200, body = GetApiKeySuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn get_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ApiKeyPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<GetApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let row = get_api_key_by_token(GetApiKeyByTokenArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_api_key_by_token failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
.filter(|row| row.owner_user_token == user_session.user_token)
|
||||
.ok_or(CommonWebError::NotFound)?;
|
||||
|
||||
Ok(Json(GetApiKeySuccessResponse {
|
||||
success: true,
|
||||
api_key: api_key_row_to_info(row),
|
||||
}))
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::list_api_keys::{ListApiKeysQueryParams, ListApiKeysSuccessResponse};
|
||||
use mysql_queries::queries::api_keys::list_api_keys_for_user::{
|
||||
list_api_keys_for_user, ListApiKeysForUserArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
/// List the logged-in user's API keys, newest first, **including
|
||||
/// soft-deleted** keys. Paginated via `limit`/`offset`. Never returns the full
|
||||
/// secret — each entry carries only the truncated key prefix.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/list",
|
||||
params(ListApiKeysQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListApiKeysSuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_api_keys_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListApiKeysQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListApiKeysSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let rows = list_api_keys_for_user(ListApiKeysForUserArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
limit,
|
||||
offset,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_api_keys_for_user failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let api_keys = rows.into_iter().map(api_key_row_to_info).collect();
|
||||
|
||||
Ok(Json(ListApiKeysSuccessResponse {
|
||||
success: true,
|
||||
api_keys,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod api_key_info_conversion;
|
||||
pub mod create_api_key_handler;
|
||||
pub mod delete_api_key_handler;
|
||||
pub mod get_api_key_handler;
|
||||
pub mod list_api_keys_handler;
|
||||
pub mod update_api_key_handler;
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
|
||||
use artcraft_api_defs::api_keys::update_api_key::{UpdateApiKeyRequest, UpdateApiKeySuccessResponse};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::api_keys::get_api_key_by_token::{
|
||||
get_api_key_by_token, GetApiKeyByTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::api_keys::update_api_key::{update_api_key, UpdateApiKeyArgs};
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_DESCRIPTION_LEN: usize = 512;
|
||||
|
||||
/// Update an API key's description (also refreshing its update IP), scoped to
|
||||
/// the logged-in user. `NotFound` if the key doesn't exist, belongs to another
|
||||
/// user, or has been deleted.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/{api_key_token}",
|
||||
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
|
||||
request_body = UpdateApiKeyRequest,
|
||||
responses(
|
||||
(status = 200, body = UpdateApiKeySuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn update_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ApiKeyPathInfo>,
|
||||
request: Json<UpdateApiKeyRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<UpdateApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
if let Some(description) = &request.maybe_description {
|
||||
if description.len() > MAX_DESCRIPTION_LEN {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("description too long (max {} chars)", MAX_DESCRIPTION_LEN),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the key exists and is owned by this user before updating (the
|
||||
// update query isn't owner-scoped). NotFound for missing/other-owned keys.
|
||||
let existing = get_api_key_by_token(GetApiKeyByTokenArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_api_key_by_token failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
.filter(|row| row.owner_user_token == user_session.user_token)
|
||||
.ok_or(CommonWebError::NotFound)?;
|
||||
|
||||
if existing.maybe_deleted_at.is_some() {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
update_api_key(UpdateApiKeyArgs {
|
||||
token: &path.api_key_token,
|
||||
maybe_description: request.maybe_description.as_deref(),
|
||||
ip_address: &ip_address,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("update_api_key failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(UpdateApiKeySuccessResponse { success: true }))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analytics;
|
||||
pub mod api_keys;
|
||||
pub mod app_state;
|
||||
pub mod beta_keys;
|
||||
pub mod billing_fakeyou;
|
||||
|
||||
+4
@@ -86,6 +86,10 @@ pub async fn enqueue_gptsovits_model_download_handler(
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
|
||||
// so release it now rather than holding a pool slot for the rest of the handler.
|
||||
drop(mysql_connection);
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
let plan = get_correct_plan_for_session(server_state.server_environment, Some(&user_session));
|
||||
|
||||
+7
-1
@@ -143,14 +143,20 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
// ==================== PIPELINE ==================== //
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
resolved_media: &resolved_media,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateImageResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
|
||||
+12
-3
@@ -25,18 +25,20 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesA
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateImageRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`finalize_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires a
|
||||
// short-lived connection only for the wallet deduction. Holding a pooled connection across the
|
||||
// external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(
|
||||
args: RunPipelineV2Args<'_>,
|
||||
) -> Result<ImagePipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
resolved_media,
|
||||
} = args;
|
||||
@@ -55,17 +57,24 @@ pub async fn run_pipeline_v2(
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
let maybe_wallet_ledger_entry_token = if cost > 0 {
|
||||
// NB: Short-lived connection — released before the external call below.
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to acquire MySQL connection for image billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
let deduction = attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
mysql_connection,
|
||||
&mut billing_connection,
|
||||
).await?;
|
||||
Some(deduction.ledger_entry_token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// NB: No pooled DB connection is held across this external generation call.
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
|
||||
|
||||
Ok(ImagePipelineResult {
|
||||
|
||||
+7
-1
@@ -200,16 +200,22 @@ pub async fn omni_gen_video_generate_handler(
|
||||
warn!("Failed to insert HTTP request debug log: {:?}", err);
|
||||
}
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map: &media_file_to_url_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateVideoResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
|
||||
+23
-9
@@ -3,7 +3,6 @@ use std::convert::TryFrom;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
@@ -28,18 +27,20 @@ use crate::state::server_state::ServerState;
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateVideoRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub media_file_to_url_map: &'a Option<HashMap<MediaFileToken, String>>,
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires
|
||||
// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled
|
||||
// connection across the external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map,
|
||||
kinovi_character_id_map,
|
||||
@@ -137,11 +138,19 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
|
||||
info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates);
|
||||
|
||||
// 3. Bill wallet
|
||||
let billing = bill_wallet(user_token, cost, mysql_connection).await?;
|
||||
// 3. Bill wallet (short-lived connection — released before the external call below).
|
||||
let billing = {
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
error!("Failed to acquire MySQL connection for billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
bill_wallet(user_token, cost, &mut billing_connection).await?
|
||||
};
|
||||
|
||||
// 4. Upload media (if draft) and generate video.
|
||||
// The entire block is wrapped so Kinovi failures trigger a refund.
|
||||
// NB: No pooled DB connection is held across this call.
|
||||
let result = upload_and_generate(
|
||||
draft_or_request,
|
||||
server_state,
|
||||
@@ -156,10 +165,15 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() {
|
||||
warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err);
|
||||
|
||||
let result = refund_wallet_after_api_failure(ledger_entry_token, mysql_connection).await;
|
||||
|
||||
if let Err(refund_err) = result {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
match server_state.mysql_pool.acquire().await {
|
||||
Ok(mut refund_connection) => {
|
||||
if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
}
|
||||
}
|
||||
Err(acquire_err) => {
|
||||
error!("Failed to acquire MySQL connection to refund wallet after Kinovi v2 failure: {:?}", acquire_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -192,6 +192,10 @@ pub async fn enqueue_studio_gen2_handler(
|
||||
CommonWebError::BadInputWithSimpleMessage("invalid idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
|
||||
// so release it now rather than holding a pool slot for the rest of the handler.
|
||||
drop(mysql_connection);
|
||||
|
||||
// ==================== LOOK UP MODEL INFO ==================== //
|
||||
|
||||
// TODO(bt): CHECK DATABASE FOR TOKENS!
|
||||
|
||||
+2
@@ -1,4 +1,5 @@
|
||||
use crate::http_server::routes::application_routes::analytics_routes::add_analytics_routes;
|
||||
use crate::http_server::routes::application_routes::api_keys_routes::add_api_keys_routes;
|
||||
use crate::http_server::routes::application_routes::billing_fakeyou_routes::add_billing_fakeyou_routes;
|
||||
use crate::http_server::routes::application_routes::character_routes::add_character_routes;
|
||||
use crate::http_server::routes::application_routes::comments_routes::add_comments_routes;
|
||||
@@ -57,6 +58,7 @@ where
|
||||
// Media files routes
|
||||
app = add_media_file_routes(app); // /v1/media_files/...
|
||||
app = add_folders_routes(app); // /v1/folders/...
|
||||
app = add_api_keys_routes(app); // /v1/api_keys/...
|
||||
app = add_featured_item_routes(app); // /v1/featured_item/...
|
||||
app = add_prompts_routes(app); // /v1/prompts/...
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
use actix_http::body::MessageBody;
|
||||
use actix_service::ServiceFactory;
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::{web, App, Error, HttpResponse};
|
||||
|
||||
use crate::http_server::endpoints::api_keys::create_api_key_handler::create_api_key_handler;
|
||||
use crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler;
|
||||
use crate::http_server::endpoints::api_keys::get_api_key_handler::get_api_key_handler;
|
||||
use crate::http_server::endpoints::api_keys::list_api_keys_handler::list_api_keys_handler;
|
||||
use crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler;
|
||||
|
||||
pub fn add_api_keys_routes<T, B>(app: App<T>) -> App<T>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
Error = Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
app.service(
|
||||
web::scope("/v1/api_keys")
|
||||
// NB: Static routes are registered BEFORE the dynamic `/{api_key_token}`
|
||||
// route so they take precedence. API key tokens are always `api_key_…`,
|
||||
// so they can never collide with these literals anyway.
|
||||
.service(
|
||||
web::resource("/create")
|
||||
.route(web::post().to(create_api_key_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/list")
|
||||
.route(web::get().to(list_api_keys_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{api_key_token}")
|
||||
.route(web::get().to(get_api_key_handler))
|
||||
.route(web::delete().to(delete_api_key_handler))
|
||||
.route(web::put().to(update_api_key_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod analytics_routes;
|
||||
mod api_keys_routes;
|
||||
mod billing_fakeyou_routes;
|
||||
mod character_routes;
|
||||
mod comments_routes;
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
use actix_http::header::{HeaderMap, HeaderName};
|
||||
use actix_web::HttpRequest;
|
||||
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
|
||||
const AUTHORIZATION_HEADER_NAME: HeaderName = HeaderName::from_static("authorization");
|
||||
|
||||
/// Extract an [`ArtcraftApiKey`] from a request's `Authorization` header.
|
||||
///
|
||||
/// The header name is matched case-insensitively (HTTP header names always are). The value parsing
|
||||
/// — the `Bearer <api_key>`, `Key <api_key>`, and bare `<api_key>` forms — lives in the
|
||||
/// [`artcraft_api_keys`] crate. Returns `None` if the header is absent, unreadable (non-ASCII
|
||||
/// bytes), or does not contain a usable key.
|
||||
pub fn get_authorization_header_api_key(http_request: &HttpRequest) -> Option<ArtcraftApiKey> {
|
||||
let header_map: &HeaderMap = http_request.headers();
|
||||
let header_value = header_map.get(AUTHORIZATION_HEADER_NAME)?
|
||||
.to_str()
|
||||
.ok()?;
|
||||
|
||||
ArtcraftApiKey::parse_from_authorization_header_value(header_value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::test::TestRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
mod request_header_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bearer_scheme() {
|
||||
assert_eq!(
|
||||
api_key_for_authorization_header(&format!("Bearer {SAMPLE_KEY}")),
|
||||
Some(SAMPLE_KEY.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_scheme() {
|
||||
assert_eq!(
|
||||
api_key_for_authorization_header(&format!("Key {SAMPLE_KEY}")),
|
||||
Some(SAMPLE_KEY.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_key_aws_style() {
|
||||
assert_eq!(
|
||||
api_key_for_authorization_header(SAMPLE_KEY),
|
||||
Some(SAMPLE_KEY.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_name_is_case_insensitive() {
|
||||
let value = format!("Bearer {SAMPLE_KEY}");
|
||||
for header_name in ["authorization", "Authorization", "AUTHORIZATION"] {
|
||||
let http_request = TestRequest::default()
|
||||
.insert_header((header_name, value.as_str()))
|
||||
.to_http_request();
|
||||
assert_eq!(
|
||||
get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful()),
|
||||
Some(SAMPLE_KEY.to_string()),
|
||||
"failed for header name {header_name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_header_returns_none() {
|
||||
let http_request = TestRequest::default().to_http_request();
|
||||
assert_eq!(get_authorization_header_api_key(&http_request), None);
|
||||
}
|
||||
}
|
||||
|
||||
mod parsing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bearer_scheme_is_case_insensitive() {
|
||||
assert_eq!(parse_authorization_header_api_key("Bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key("bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key("BEARER abc"), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key("BeArEr abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_scheme_is_case_insensitive() {
|
||||
assert_eq!(parse_authorization_header_api_key("Key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key("key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key("KEY abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_key_aws_style() {
|
||||
assert_eq!(parse_authorization_header_api_key("abc"), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrounding_and_inner_whitespace_is_trimmed() {
|
||||
assert_eq!(parse_authorization_header_api_key(" Bearer abc "), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key("\tKey\tabc\t"), Some("abc".to_string()));
|
||||
assert_eq!(parse_authorization_header_api_key(" abc "), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_blank_is_none() {
|
||||
assert_eq!(parse_authorization_header_api_key(""), None);
|
||||
assert_eq!(parse_authorization_header_api_key(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_with_no_credential_is_none() {
|
||||
assert_eq!(parse_authorization_header_api_key("Bearer"), None);
|
||||
assert_eq!(parse_authorization_header_api_key("Bearer "), None);
|
||||
assert_eq!(parse_authorization_header_api_key("Key"), None);
|
||||
assert_eq!(parse_authorization_header_api_key("key "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_scheme_is_none() {
|
||||
assert_eq!(parse_authorization_header_api_key("Basic dXNlcjpwYXNz"), None);
|
||||
assert_eq!(parse_authorization_header_api_key("Digest abc"), None);
|
||||
// Two non-scheme tokens — a bare key never contains whitespace.
|
||||
assert_eq!(parse_authorization_header_api_key("abc def"), None);
|
||||
}
|
||||
}
|
||||
|
||||
// Thin string-returning adapters over the real implementation so the original test cases above
|
||||
// can stay unchanged after the parser moved into the `artcraft_api_keys` crate.
|
||||
|
||||
fn parse_authorization_header_api_key(header_value: &str) -> Option<String> {
|
||||
ArtcraftApiKey::parse_from_authorization_header_value(header_value)
|
||||
.map(|key| key.to_string_be_careful())
|
||||
}
|
||||
|
||||
fn api_key_for_authorization_header(value: &str) -> Option<String> {
|
||||
let http_request = TestRequest::default()
|
||||
.insert_header(("Authorization", value))
|
||||
.to_http_request();
|
||||
get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod bucket_urls;
|
||||
pub mod filter_model_name;
|
||||
pub mod get_authorization_header_api_key;
|
||||
pub mod get_host_header;
|
||||
pub mod get_request_platform_type;
|
||||
pub mod handle_multipart_error;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
use rand::Rng;
|
||||
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
|
||||
/// All Artcraft API keys begin with this prefix. It counts toward the total key length.
|
||||
///
|
||||
/// The underscores in this literal are the ONLY underscores permitted in a key — the random
|
||||
/// suffix is pure Crockford-lowercase alphanumeric. (Well-known keys do this too: Stripe uses
|
||||
/// `sk_live_<random>`, GitHub `ghp_<random>`, etc. — a readable prefix plus a long random body.)
|
||||
const API_KEY_PREFIX: &str = "artcraft_api_";
|
||||
|
||||
/// Crockford base32 lowercase alphabet — 32 symbols, deliberately excluding the ambiguous
|
||||
/// `i`, `l`, `o`, `u`. Every symbol is alphanumeric, so keys never contain `.`, `+`, `/`, `_`,
|
||||
/// or any other punctuation in the random body. Each symbol carries log2(32) = 5 bits.
|
||||
///
|
||||
/// NB: Unlike Stripe/GitHub keys (which use a full base-36 `0-9a-z` alphabet), ours is Crockford
|
||||
/// so the body is unambiguous to read/transcribe.
|
||||
const CROCKFORD_LOWERCASE_CHARSET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
/// Number of random Crockford characters in the entropy suffix.
|
||||
///
|
||||
/// 40 symbols * 5 bits = **200 bits** of entropy — far above the ~128-bit cryptographic-security
|
||||
/// floor, and ahead of a typical Stripe secret key (~24 base-36 chars ≈ 124 bits). The total key
|
||||
/// length is therefore `API_KEY_PREFIX.len() + 40` = 53 characters.
|
||||
const ENTROPY_CHAR_COUNT: usize = 40;
|
||||
|
||||
/// Generate a cryptographically-secure Artcraft API key, e.g.
|
||||
/// `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t...` (40 Crockford chars after the prefix).
|
||||
///
|
||||
/// Infallible: it draws from the thread-local CSPRNG (`rand::rng()`, a ChaCha-based, OS-seeded
|
||||
/// generator), which never fails.
|
||||
///
|
||||
/// The full secret lives inside the returned [`ArtcraftApiKey`]; use `as_str()` to obtain it for
|
||||
/// storage (`Debug`/`Display` deliberately redact it).
|
||||
pub fn generate_api_key() -> ArtcraftApiKey {
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let mut key = String::with_capacity(API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
|
||||
key.push_str(API_KEY_PREFIX);
|
||||
|
||||
for _ in 0..ENTROPY_CHAR_COUNT {
|
||||
let index = rng.random_range(0..CROCKFORD_LOWERCASE_CHARSET.len());
|
||||
key.push(CROCKFORD_LOWERCASE_CHARSET[index] as char);
|
||||
}
|
||||
|
||||
ArtcraftApiKey(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn starts_with_prefix() {
|
||||
assert!(generate_api_key().as_str_be_careful().starts_with("artcraft_api_"));
|
||||
assert!(generate_api_key().as_str_be_careful().starts_with(API_KEY_PREFIX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_expected_total_length() {
|
||||
assert_eq!(generate_api_key().as_str_be_careful().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_suffix_is_crockford_lowercase() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
|
||||
|
||||
assert_eq!(suffix.len(), ENTROPY_CHAR_COUNT);
|
||||
for byte in suffix.bytes() {
|
||||
assert!(
|
||||
CROCKFORD_LOWERCASE_CHARSET.contains(&byte),
|
||||
"unexpected character {:?} in entropy suffix",
|
||||
byte as char,
|
||||
);
|
||||
}
|
||||
// Crockford excludes the ambiguous i, l, o, u.
|
||||
assert!(!suffix.contains(['i', 'l', 'o', 'u']));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_characters_are_absent() {
|
||||
let key = generate_api_key();
|
||||
for forbidden in ['.', '+', '/'] {
|
||||
assert!(!key.as_str_be_careful().contains(forbidden), "key must not contain {:?}", forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscores_only_appear_in_the_prefix() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
|
||||
assert!(!suffix.contains('_'), "the random body must not contain underscores");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_suffix_is_alphanumeric() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.as_str_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
|
||||
assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_is_cryptographically_sufficient() {
|
||||
// 32-symbol alphabet => 5 bits per character. Require a comfortable margin over 128 bits.
|
||||
let bits = ENTROPY_CHAR_COUNT * 5;
|
||||
assert!(bits >= 128, "insufficient entropy: {bits} bits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_unique() {
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
assert!(seen.insert(generate_api_key()), "generated a duplicate key");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod cleaners;
|
||||
pub mod delete_role_disambiguation;
|
||||
pub mod encrypted_sort_id;
|
||||
pub mod enroll_in_studio;
|
||||
pub mod generate_api_key;
|
||||
pub mod http_download_url_to_bytes;
|
||||
pub mod http_download_url_to_tempfile;
|
||||
pub mod lookup;
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ ios = { level = "full", notes = "" }
|
||||
|
||||
[build-dependencies]
|
||||
#tauri-plugin = { workspace = true, features = ["build"] }
|
||||
tauri-plugin = { version = "2.2.0", features = ["build"] }
|
||||
tauri-plugin = { version = "2.6.2", features = ["build"] }
|
||||
#schemars = { workspace = true }
|
||||
schemars = { version = "0.8" }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -91,7 +91,7 @@ sqlx-sqlite = { version = "0.7.3", default-features = false, features = ["any",
|
||||
stable_deref_trait = { version = "1.2.0" }
|
||||
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
|
||||
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
|
||||
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
|
||||
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
|
||||
time = { version = "0.3.36", features = ["local-offset", "macros", "serde-well-known"] }
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.16", features = ["fs"] }
|
||||
@@ -171,8 +171,8 @@ stable_deref_trait = { version = "1.2.0" }
|
||||
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
|
||||
syn-dff4ba8e3ae991db = { package = "syn", version = "1.0.109", features = ["extra-traits", "fold", "full", "visit-mut"] }
|
||||
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
|
||||
tauri-build = { version = "2.1.1" }
|
||||
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
|
||||
tauri-build = { version = "2.6.2" }
|
||||
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
|
||||
time-macros = { version = "0.2.18", default-features = false, features = ["formatting", "parsing", "serde"] }
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.16", features = ["fs"] }
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faSpinnerThird,
|
||||
faCopy,
|
||||
faCheck,
|
||||
faKey,
|
||||
faTrash,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
UserApiKeysApi,
|
||||
type ApiKeyInfo,
|
||||
type CreatedApiKey,
|
||||
type UserInfo,
|
||||
} from "@storyteller/api";
|
||||
import { toast } from "../toast/toast";
|
||||
|
||||
const NAME_MAX = 255;
|
||||
const DESCRIPTION_MAX = 512;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const INPUT_CLASS =
|
||||
"w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
|
||||
|
||||
interface ApiKeySectionProps {
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
export function ApiKeySection(_props: ApiKeySectionProps) {
|
||||
const [api] = useState(() => new UserApiKeysApi());
|
||||
|
||||
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
|
||||
// Rows fetched so far (including deleted ones we hide), drives the next
|
||||
// offset. The backend returns no total count, so we offer "Load more" while
|
||||
// the last page came back full.
|
||||
const [fetchedCount, setFetchedCount] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingToken, setEditingToken] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<ApiKeyInfo | null>(null);
|
||||
// The full secret, shown exactly once right after a key is created.
|
||||
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
|
||||
|
||||
// `reset` reloads from offset 0; otherwise appends the next page.
|
||||
const load = async (reset: boolean) => {
|
||||
if (reset) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
const offset = reset ? 0 : fetchedCount;
|
||||
const response = await api.ListApiKeys({ limit: PAGE_SIZE, offset });
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
if (response.success && response.data) {
|
||||
const fetched = response.data.api_keys;
|
||||
// The list includes soft-deleted keys; only show live ones.
|
||||
const live = fetched.filter((k) => k.maybe_deleted_at === null);
|
||||
setKeys((prev) => (reset ? live : [...prev, ...live]));
|
||||
setFetchedCount((prev) => (reset ? fetched.length : prev + fetched.length));
|
||||
setHasMore(fetched.length === PAGE_SIZE);
|
||||
} else {
|
||||
toast.error(response.errorMessage ?? "Could not load API keys.");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load(true);
|
||||
// Load once on mount; pagination drives subsequent loads explicitly.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleCreated = (created: CreatedApiKey) => {
|
||||
setCreating(false);
|
||||
// Reveal the full secret once; the truncated key then appears in the list.
|
||||
setCreatedKey(created);
|
||||
load(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirmed = async () => {
|
||||
if (!pendingDelete) return;
|
||||
const target = pendingDelete;
|
||||
setPendingDelete(null);
|
||||
const response = await api.DeleteApiKey({ token: target.token });
|
||||
if (response.success) {
|
||||
toast.success("API key deleted.");
|
||||
// Drop it locally so the list updates without a full reload.
|
||||
setKeys((prev) => prev.filter((k) => k.token !== target.token));
|
||||
} else {
|
||||
toast.error(response.errorMessage ?? "Could not delete API key.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">API keys</p>
|
||||
<p className="text-xs opacity-70">
|
||||
Create and manage keys to access the API programmatically.
|
||||
</p>
|
||||
</div>
|
||||
{!creating && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-9 px-3 shrink-0"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
Create key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createdKey && (
|
||||
<NewKeyReveal
|
||||
created={createdKey}
|
||||
onDismiss={() => setCreatedKey(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<CreateKeyForm
|
||||
api={api}
|
||||
onCreated={handleCreated}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<hr className="border-ui-panel-border" />
|
||||
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-xs opacity-60">
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs opacity-60">
|
||||
You don't have any API keys yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{keys.map((item) => (
|
||||
<ApiKeyRow
|
||||
key={item.token}
|
||||
api={api}
|
||||
item={item}
|
||||
isEditing={editingToken === item.token}
|
||||
onOpenEdit={() => setEditingToken(item.token)}
|
||||
onCloseEdit={() => setEditingToken(null)}
|
||||
onUpdated={(updated) => {
|
||||
setEditingToken(null);
|
||||
setKeys((prev) =>
|
||||
prev.map((k) => (k.token === updated.token ? updated : k)),
|
||||
);
|
||||
}}
|
||||
onRequestDelete={() => setPendingDelete(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-8 px-3"
|
||||
disabled={loadingMore}
|
||||
onClick={() => load(false)}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Load more"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteConfirmModal
|
||||
item={pendingDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={handleDeleteConfirmed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateKeyForm({
|
||||
api,
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
api: UserApiKeysApi;
|
||||
onCreated: (created: CreatedApiKey) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const canSubmit = trimmedName.length > 0;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!canSubmit) {
|
||||
setError("Enter a name for the key.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const trimmedDescription = description.trim();
|
||||
const response = await api.CreateApiKey({
|
||||
name: trimmedName,
|
||||
maybeDescription:
|
||||
trimmedDescription.length > 0 ? trimmedDescription : undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (response.success && response.data) {
|
||||
toast.success("API key created.");
|
||||
onCreated(response.data);
|
||||
} else {
|
||||
setError(response.errorMessage ?? "Could not create API key.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name (required)"
|
||||
maxLength={NAME_MAX}
|
||||
autoFocus
|
||||
inputClassName={INPUT_CLASS}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
maxLength={DESCRIPTION_MAX}
|
||||
inputClassName={INPUT_CLASS}
|
||||
/>
|
||||
{error && <FormError message={error} />}
|
||||
<FormActions
|
||||
onCancel={onCancel}
|
||||
submitting={submitting}
|
||||
disabled={!canSubmit}
|
||||
submitLabel="Create"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function NewKeyReveal({
|
||||
created,
|
||||
onDismiss,
|
||||
}: {
|
||||
created: CreatedApiKey;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(created.api_key);
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard.");
|
||||
} catch {
|
||||
toast.error("Could not copy. Select and copy it manually.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/10 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FontAwesomeIcon icon={faKey} className="opacity-80" />
|
||||
<span>API key created</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-80">
|
||||
Copy your key now. For security, you won't be able to see it again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-white">
|
||||
{created.api_key}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-9 px-3 shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1.5" />
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-8 px-3"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyRow({
|
||||
api,
|
||||
item,
|
||||
isEditing,
|
||||
onOpenEdit,
|
||||
onCloseEdit,
|
||||
onUpdated,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
api: UserApiKeysApi;
|
||||
item: ApiKeyInfo;
|
||||
isEditing: boolean;
|
||||
onOpenEdit: () => void;
|
||||
onCloseEdit: () => void;
|
||||
onUpdated: (updated: ApiKeyInfo) => void;
|
||||
onRequestDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-ui-panel-border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className="truncate text-sm font-medium">{item.name}</p>
|
||||
{item.maybe_description && (
|
||||
<p className="truncate text-xs opacity-70">
|
||||
{item.maybe_description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{/* Truncated, display-only — the full secret is only shown on create. */}
|
||||
<code className="min-w-0 truncate rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-white/70">
|
||||
{item.truncated_api_key}
|
||||
</code>
|
||||
<span className="text-[10px] uppercase tracking-wider text-white/40">
|
||||
{formatDate(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-8 px-3"
|
||||
onClick={onOpenEdit}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-8 px-2.5 text-red-400"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<EditDescriptionForm
|
||||
api={api}
|
||||
item={item}
|
||||
onUpdated={onUpdated}
|
||||
onCancel={onCloseEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditDescriptionForm({
|
||||
api,
|
||||
item,
|
||||
onUpdated,
|
||||
onCancel,
|
||||
}: {
|
||||
api: UserApiKeysApi;
|
||||
item: ApiKeyInfo;
|
||||
onUpdated: (updated: ApiKeyInfo) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const initial = item.maybe_description ?? "";
|
||||
const [value, setValue] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const trimmed = value.trim();
|
||||
const isDirty = trimmed !== initial.trim();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!isDirty) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const nextDescription = trimmed.length > 0 ? trimmed : null;
|
||||
const response = await api.UpdateApiKey({
|
||||
token: item.token,
|
||||
maybeDescription: nextDescription,
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (response.success) {
|
||||
toast.success("API key updated.");
|
||||
// Update endpoint returns only { success }; merge the new description in.
|
||||
onUpdated({ ...item, maybe_description: nextDescription });
|
||||
} else {
|
||||
setError(response.errorMessage ?? "Could not update API key.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 pt-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
maxLength={DESCRIPTION_MAX}
|
||||
autoFocus
|
||||
inputClassName={INPUT_CLASS}
|
||||
/>
|
||||
{error && <FormError message={error} />}
|
||||
<FormActions
|
||||
onCancel={onCancel}
|
||||
submitting={submitting}
|
||||
disabled={!isDirty}
|
||||
submitLabel="Save"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteConfirmModal({
|
||||
item,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
item: ApiKeyInfo | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setDeleting(true);
|
||||
await onConfirm();
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={item !== null} onClose={onCancel} className="max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base font-semibold">Delete API key</h3>
|
||||
<p className="text-sm opacity-70">
|
||||
Delete "{item?.name}"? Any application using this key will stop
|
||||
working. This can't be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-9 px-3"
|
||||
onClick={onCancel}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-9 px-4 bg-red-500 hover:bg-red-600"
|
||||
onClick={handleConfirm}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FormActions({
|
||||
onCancel,
|
||||
submitting,
|
||||
disabled,
|
||||
submitLabel,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
submitting: boolean;
|
||||
disabled: boolean;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-9 px-3"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="h-9 px-4"
|
||||
disabled={submitting || disabled}
|
||||
>
|
||||
{submitting ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormError({ message }: { message: string }) {
|
||||
return <p className="text-xs text-red-400 leading-tight">{message}</p>;
|
||||
}
|
||||
|
||||
function formatDate(raw: string): string {
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -1,36 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCog, faUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faCog, faUser, faKey } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Switch } from "@storyteller/ui-switch";
|
||||
import { USER_FEATURE_FLAGS } from "@storyteller/api";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { AccountSection } from "./AccountSection";
|
||||
import { ApiKeySection } from "./ApiKeySection";
|
||||
|
||||
type Tab = "general" | "account";
|
||||
type Tab = "general" | "account" | "apiKeys";
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
|
||||
const BASE_TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
|
||||
{ id: "general", label: "General", icon: faCog },
|
||||
{ id: "account", label: "Account", icon: faUser },
|
||||
];
|
||||
|
||||
const API_KEYS_TAB: { id: Tab; label: string; icon: typeof faCog } = {
|
||||
id: "apiKeys",
|
||||
label: "API Keys",
|
||||
icon: faKey,
|
||||
};
|
||||
|
||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { user } = useSession();
|
||||
const [tab, setTab] = useState<Tab>("general");
|
||||
|
||||
const hasApiKeyFlag = !!user?.maybe_feature_flags?.includes(
|
||||
USER_FEATURE_FLAGS.API_KEY,
|
||||
);
|
||||
|
||||
const tabs = useMemo(
|
||||
() => (hasApiKeyFlag ? [...BASE_TABS, API_KEYS_TAB] : BASE_TABS),
|
||||
[hasApiKeyFlag],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setTab("general");
|
||||
}, [isOpen]);
|
||||
|
||||
const activeLabel = TABS.find((t) => t.id === tab)?.label ?? "";
|
||||
// If the only-conditional tab disappears (e.g. flag revoked while open),
|
||||
// fall back to a visible tab.
|
||||
useEffect(() => {
|
||||
if (!tabs.some((t) => t.id === tab)) setTab("general");
|
||||
}, [tabs, tab]);
|
||||
|
||||
const activeLabel = tabs.find((t) => t.id === tab)?.label ?? "";
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} className="max-w-3xl" childPadding={false}>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
className="max-w-3xl"
|
||||
childPadding={false}
|
||||
>
|
||||
<div className="h-[100dvh] sm:h-[560px]">
|
||||
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
|
||||
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
|
||||
@@ -39,7 +68,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
|
||||
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
|
||||
{TABS.map((t) => (
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
@@ -64,6 +93,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
|
||||
{tab === "general" && <GeneralPanel />}
|
||||
{tab === "account" && <AccountPanel />}
|
||||
{tab === "apiKeys" && <ApiKeysPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,12 +112,16 @@ function GeneralPanel() {
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">Enter to generate</p>
|
||||
<p className="text-xs opacity-70">
|
||||
When on, pressing Enter submits the prompt and Shift+Enter
|
||||
adds a new line. When off (default), both Enter and
|
||||
Shift+Enter add a new line, use the button to submit.
|
||||
When on, pressing Enter submits the prompt and Shift+Enter adds a
|
||||
new line. When off (default), both Enter and Shift+Enter add a new
|
||||
line, use the button to submit.
|
||||
</p>
|
||||
</div>
|
||||
<Switch enabled={enterToGenerate} setEnabled={setEnterToGenerate} offClassName="bg-white/20" />
|
||||
<Switch
|
||||
enabled={enterToGenerate}
|
||||
setEnabled={setEnterToGenerate}
|
||||
offClassName="bg-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -116,3 +150,25 @@ function AccountPanel() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysPanel() {
|
||||
const { user, authChecked } = useSession();
|
||||
|
||||
if (!authChecked) {
|
||||
return <div className="pt-3 text-xs opacity-60">Loading API keys...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="pt-3 text-xs opacity-60">
|
||||
You need to be signed in to manage API keys.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-3">
|
||||
<ApiKeySection user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-upload": "2.2.1"
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-upload": "2.4.0"
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export * from "./lib/PromptsApi.js";
|
||||
export * from "./lib/TtsApi.js";
|
||||
export * from "./lib/UserBookmarksApi.js";
|
||||
export * from "./lib/PasswordResetApi.js";
|
||||
export * from "./lib/UserApiKeysApi.js";
|
||||
export * from "./lib/UserReferralCodesApi.js";
|
||||
export * from "./lib/UsersApi.js";
|
||||
export * from "./lib/VideoApi.js";
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ApiManager, ApiResponse, buildSessionHeaders } from "./ApiManager.js";
|
||||
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
|
||||
|
||||
// Canonical wire shape for an API key (list rows + single-key GET). Mirrors the
|
||||
// backend `ApiKeyInfo`. The full secret is NEVER here — only
|
||||
// `truncated_api_key` (first 20 chars). The full value is returned exactly once
|
||||
// at creation time (see CreatedApiKey).
|
||||
export interface ApiKeyInfo {
|
||||
token: string;
|
||||
truncated_api_key: string;
|
||||
name: string;
|
||||
maybe_description: string | null;
|
||||
owner_user_token: string;
|
||||
ip_address_creation: string;
|
||||
ip_address_update: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Soft-delete timestamp; null means live. The list includes deleted keys, so
|
||||
// the UI filters these out.
|
||||
maybe_deleted_at: string | null;
|
||||
}
|
||||
|
||||
// Create response: the `api_key_token` (used for all subsequent management) plus
|
||||
// the full secret `api_key`, returned exactly once.
|
||||
export interface CreatedApiKey {
|
||||
api_key_token: string;
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
success: boolean;
|
||||
error_code?: number;
|
||||
error_code_str?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class UserApiKeysApi extends ApiManager {
|
||||
public ListApiKeys({
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}): Promise<ApiResponse<{ api_keys: ApiKeyInfo[] }>> {
|
||||
const query = new URLSearchParams();
|
||||
if (limit !== undefined) query.set("limit", String(limit));
|
||||
if (offset !== undefined) query.set("offset", String(offset));
|
||||
const suffix = query.toString() ? `?${query.toString()}` : "";
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/list${suffix}`;
|
||||
return this.jsonFetch<{
|
||||
success: boolean;
|
||||
api_keys?: ApiKeyInfo[];
|
||||
} & ErrorBody>(endpoint, { method: "GET" })
|
||||
.then((response) => {
|
||||
if (!response.success) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: response.message ?? this.statusFallback(response),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: { api_keys: response.api_keys ?? [] },
|
||||
};
|
||||
})
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
// Single-key lookup. Not currently used by the settings UI (the list already
|
||||
// carries everything), but provided for parity with the backend.
|
||||
public GetApiKey({
|
||||
token,
|
||||
}: {
|
||||
token: string;
|
||||
}): Promise<ApiResponse<ApiKeyInfo>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
|
||||
return this.jsonFetch<
|
||||
{ success: boolean; api_key?: ApiKeyInfo } & ErrorBody
|
||||
>(endpoint, { method: "GET" })
|
||||
.then((response) => {
|
||||
if (!response.success || !response.api_key) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: response.message ?? this.statusFallback(response),
|
||||
};
|
||||
}
|
||||
return { success: true, data: response.api_key };
|
||||
})
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
public CreateApiKey({
|
||||
name,
|
||||
maybeDescription,
|
||||
}: {
|
||||
name: string;
|
||||
maybeDescription?: string;
|
||||
}): Promise<ApiResponse<CreatedApiKey>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/create`;
|
||||
return this.jsonFetch<
|
||||
{
|
||||
success: boolean;
|
||||
api_key_token?: string;
|
||||
api_key?: string;
|
||||
} & ErrorBody
|
||||
>(endpoint, {
|
||||
method: "POST",
|
||||
body: { name, maybe_description: maybeDescription ?? null },
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.success || !response.api_key_token || !response.api_key) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: response.message ?? this.statusFallback(response),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
api_key_token: response.api_key_token,
|
||||
api_key: response.api_key,
|
||||
},
|
||||
};
|
||||
})
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
public UpdateApiKey({
|
||||
token,
|
||||
maybeDescription,
|
||||
}: {
|
||||
token: string;
|
||||
maybeDescription: string | null;
|
||||
}): Promise<ApiResponse<null>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
|
||||
return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, {
|
||||
method: "PUT",
|
||||
body: { maybe_description: maybeDescription },
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.success) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: response.message ?? this.statusFallback(response),
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
})
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
public DeleteApiKey({ token }: { token: string }): Promise<ApiResponse<null>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
|
||||
return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.success) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: response.message ?? this.statusFallback(response),
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
})
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
// Parses JSON for both 2xx and 4xx so `BadInputWithSimpleMessage` text
|
||||
// reaches the caller. The base ApiManager.fetch throws on non-2xx.
|
||||
private async jsonFetch<T>(
|
||||
endpoint: string,
|
||||
{ method, body }: { method: string; body?: unknown },
|
||||
): Promise<T> {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: buildSessionHeaders({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
credentials: "include",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new Error(text || `Request failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
private statusFallback(response: ErrorBody): string {
|
||||
if (response.error_code_str) return response.error_code_str;
|
||||
if (response.error_code) return `Request failed (${response.error_code})`;
|
||||
return "Request failed";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user