diff --git a/.sqlx/query-097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab.json b/.sqlx/query-097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab.json new file mode 100644 index 0000000000..90cdd5ba33 --- /dev/null +++ b/.sqlx/query-097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab.json @@ -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" +} diff --git a/.sqlx/query-9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922.json b/.sqlx/query-9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922.json new file mode 100644 index 0000000000..21ba658dd5 --- /dev/null +++ b/.sqlx/query-9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922.json @@ -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" +} diff --git a/.sqlx/query-b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe.json b/.sqlx/query-b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe.json new file mode 100644 index 0000000000..e48a0ccfef --- /dev/null +++ b/.sqlx/query-b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe.json @@ -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" +} diff --git a/.sqlx/query-bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4.json b/.sqlx/query-bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4.json new file mode 100644 index 0000000000..a72b54ddcf --- /dev/null +++ b/.sqlx/query-bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4.json @@ -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" +} diff --git a/.sqlx/query-bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c.json b/.sqlx/query-bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c.json new file mode 100644 index 0000000000..33004c7802 --- /dev/null +++ b/.sqlx/query-bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c.json @@ -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" +} diff --git a/.sqlx/query-c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805.json b/.sqlx/query-c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805.json new file mode 100644 index 0000000000..30a93c65c5 --- /dev/null +++ b/.sqlx/query-c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805.json @@ -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" +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs new file mode 100644 index 0000000000..6769167106 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs @@ -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, + + pub owner_user_token: UserToken, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// Soft-delete timestamp. `None` for live keys. + pub maybe_deleted_at: Option>, +} + +/// 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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs new file mode 100644 index 0000000000..6455bb5d3c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs @@ -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, +} + +#[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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs new file mode 100644 index 0000000000..361ed4ac09 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs new file mode 100644 index 0000000000..7f131fcfbb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs new file mode 100644 index 0000000000..9e3750f903 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs @@ -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, + + /// Number of rows to skip. Defaults to 0. + pub offset: Option, +} + +#[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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs new file mode 100644 index 0000000000..e4a9b484f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs new file mode 100644 index 0000000000..c51f7193d4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs @@ -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, +} + +#[derive(Serialize, ToSchema)] +pub struct UpdateApiKeySuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs index 41d84b638b..a9d2efc0fb 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -1,4 +1,5 @@ pub mod analytics; +pub mod api_keys; pub mod characters; pub mod common; pub mod credits; diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/api_key_row.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/api_key_row.rs new file mode 100644 index 0000000000..6c2f9f2394 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/api_key_row.rs @@ -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, + + pub owner_user_token: UserToken, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + pub maybe_deleted_at: Option>, +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/delete_api_key.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/delete_api_key.rs new file mode 100644 index 0000000000..eaa944e205 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/delete_api_key.rs @@ -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 +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()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_by_token.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_by_token.rs new file mode 100644 index 0000000000..4dbd716176 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_by_token.rs @@ -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, 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, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/get_live_api_key_by_value.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/get_live_api_key_by_value.rs new file mode 100644 index 0000000000..b163b526c7 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/get_live_api_key_by_value.rs @@ -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, 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, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs new file mode 100644 index 0000000000..30787e42f1 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs @@ -0,0 +1,58 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +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 str, + 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 +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, + args.name, + args.maybe_description, + args.owner_user_token.as_str(), + args.ip_address, + args.ip_address, + ) + .execute(args.mysql_executor) + .await?; + + Ok(token) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/list_api_keys_for_user.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/list_api_keys_for_user.rs new file mode 100644 index 0000000000..97a34094b6 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/list_api_keys_for_user.rs @@ -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, 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::>(); + + Ok(rows) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs new file mode 100644 index 0000000000..898cc84f80 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs @@ -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; diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/update_api_key.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/update_api_key.rs new file mode 100644 index 0000000000..25438c1b9b --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/update_api_key.rs @@ -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 +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()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index de7e561443..bc4474b920 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 3c515fd6ee..f15b9ca09d 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -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, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/api_key_info_conversion.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/api_key_info_conversion.rs new file mode 100644 index 0000000000..973d5adccc --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/api_key_info_conversion.rs @@ -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, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs new file mode 100644 index 0000000000..4625a6a2bd --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs @@ -0,0 +1,91 @@ +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, + server_state: web::Data>, +) -> Result, 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, + api_key, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs new file mode 100644 index 0000000000..8d00af94ef --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs @@ -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, + server_state: web::Data>, +) -> Result, 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 })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs new file mode 100644 index 0000000000..7f08ebd985 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs @@ -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, + server_state: web::Data>, +) -> Result, 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), + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs new file mode 100644 index 0000000000..d3d9d41401 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs @@ -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, + server_state: web::Data>, +) -> Result, 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, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/mod.rs new file mode 100644 index 0000000000..d6baf05091 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs new file mode 100644 index 0000000000..30c2ff7c67 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs @@ -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, + request: Json, + server_state: web::Data>, +) -> Result, 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 })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index 0a7ea04bc1..4d593ba4d7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -1,4 +1,5 @@ pub mod analytics; +pub mod api_keys; pub mod app_state; pub mod beta_keys; pub mod billing_fakeyou; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs index 8983a3842d..e3051809a7 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs @@ -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/... diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/api_keys_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/api_keys_routes.rs new file mode 100644 index 0000000000..c808e7435b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/api_keys_routes.rs @@ -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(app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + 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())), + ), + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs index d22ececf7e..e7f8b9a836 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs @@ -1,4 +1,5 @@ mod analytics_routes; +mod api_keys_routes; mod billing_fakeyou_routes; mod character_routes; mod comments_routes;