Compare commits

..
Author SHA1 Message Date
bflatastic 3849a18c8e api update 2 2026-06-15 22:23:31 +07:00
bflatastic ce7b180c7e api update 2026-06-15 21:26:33 +07:00
bflatastic 3f5bc6edf8 Webapp: settings modal feature flagged api key section 2026-06-15 21:16:44 +07:00
85 changed files with 47 additions and 2126 deletions
@@ -120,7 +120,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -1,124 +0,0 @@
{
"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"
}
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
}
],
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -1,124 +0,0 @@
{
"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"
}
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -270,7 +270,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,124 +0,0 @@
{
"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"
}
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 20
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
"max_size": 11
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
"max_size": 11
}
},
{
@@ -1,12 +0,0 @@
{
"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"
}
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
"max_size": 11
}
},
{
Generated
-9
View File
@@ -712,13 +712,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_api_keys"
version = "0.0.1"
dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_client"
version = "0.0.1"
@@ -7304,7 +7297,6 @@ name = "mysql_queries"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_keys",
"bucket_paths",
"chrono",
"composite_identifiers",
@@ -11564,7 +11556,6 @@ dependencies = [
"actix_helpers",
"anyhow",
"artcraft_api_defs",
"artcraft_api_keys",
"artcraft_router",
"async-stripe 0.26.0",
"async-trait",
-2
View File
@@ -7,7 +7,6 @@ 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",
@@ -233,7 +232,6 @@ 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" }
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS api_keys;
@@ -1,50 +0,0 @@
-- 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;
@@ -1,50 +0,0 @@
-- 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;
@@ -1,42 +0,0 @@
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,
}
@@ -1,28 +0,0 @@
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,
}
@@ -1,9 +0,0 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── DELETE /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteApiKeySuccessResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
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,
}
@@ -1,24 +0,0 @@
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>,
}
@@ -1,6 +0,0 @@
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;
@@ -1,15 +0,0 @@
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,5 +1,4 @@
pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod credits;
@@ -1,20 +0,0 @@
[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
@@ -1,104 +0,0 @@
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…");
}
}
@@ -1,125 +0,0 @@
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);
}
}
@@ -20,7 +20,6 @@ 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"] }
@@ -1,27 +0,0 @@
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>>,
}
@@ -1,38 +0,0 @@
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())
}
@@ -1,63 +0,0 @@
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,
}))
}
@@ -1,66 +0,0 @@
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,
}))
}
@@ -1,59 +0,0 @@
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)
}
@@ -1,77 +0,0 @@
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)
}
@@ -1,7 +0,0 @@
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;
@@ -1,48 +0,0 @@
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,6 +1,5 @@
pub mod analytics_active_users;
pub mod api_keys;
pub mod api_tokens;
pub mod audit_logs;
pub mod batch_generations;
@@ -23,7 +23,6 @@ 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,
@@ -113,7 +112,6 @@ 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_",
@@ -350,7 +348,6 @@ mod tests {
|| prefix == "INT_API:"
|| prefix == "batch_g_"
|| prefix == "beta_key_"
|| prefix == "api_key_"
|| prefix == "email_job_"
|| prefix == "pw_reset_"
|| prefix == "tts_task_"
@@ -1,17 +0,0 @@
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,5 +1,4 @@
pub mod anonymous_visitor_tracking;
pub mod api_keys;
pub mod api_tokens_external;
pub mod api_tokens_internal;
pub mod app_session;
@@ -36,7 +36,6 @@ 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
@@ -95,19 +95,6 @@ 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::*;
@@ -574,13 +561,6 @@ 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,
@@ -1270,18 +1250,6 @@ 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,
@@ -1,20 +0,0 @@
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,
}
}
@@ -1,93 +0,0 @@
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
}))
}
@@ -1,73 +0,0 @@
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 }))
}
@@ -1,62 +0,0 @@
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),
}))
}
@@ -1,67 +0,0 @@
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,
}))
}
@@ -1,6 +0,0 @@
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;
@@ -1,92 +0,0 @@
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,5 +1,4 @@
pub mod analytics;
pub mod api_keys;
pub mod app_state;
pub mod beta_keys;
pub mod billing_fakeyou;
@@ -1,5 +1,4 @@
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;
@@ -58,7 +57,6 @@ 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/...
@@ -1,46 +0,0 @@
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,5 +1,4 @@
mod analytics_routes;
mod api_keys_routes;
mod billing_fakeyou_routes;
mod character_routes;
mod comments_routes;
@@ -1,144 +0,0 @@
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,6 +1,5 @@
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;
@@ -1,119 +0,0 @@
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,7 +7,6 @@ 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;