mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 07:26:46 -05:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1573b69780 | ||
|
|
2a2e39a1ac |
-124
@@ -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"
|
||||
}
|
||||
-124
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-124
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
Generated
+583
-513
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -342,11 +342,11 @@ sha2 = "0.11.0-rc.5"
|
||||
sha3 = "0.10.8"
|
||||
speculoos = "0.11.0"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-upload = "2.4.0"
|
||||
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.3.3"
|
||||
tauri-plugin-fs = "2.4.2"
|
||||
tauri-plugin-log = "2.6.0"
|
||||
tauri-plugin-upload = "2.3.1"
|
||||
tempdir = "0.3.7"
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,7 @@ name = "artcraft_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
tauri-build = { version = "2.1.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
-66
@@ -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,58 +0,0 @@
|
||||
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<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,
|
||||
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;
|
||||
|
||||
+6
-7
@@ -1,7 +1,8 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use errors::AnyhowResult;
|
||||
use sqlx::{Executor, MySql};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
|
||||
pub struct ActiveUserSubscription {
|
||||
pub user_token: String,
|
||||
@@ -18,12 +19,10 @@ pub struct ActiveUserSubscription {
|
||||
pub subscription_expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn list_active_user_subscriptions<'e, 'c : 'e, E>(
|
||||
mysql_executor: E,
|
||||
pub async fn list_active_user_subscriptions(
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
user_token: &str
|
||||
) -> AnyhowResult<Vec<ActiveUserSubscription>>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
) -> AnyhowResult<Vec<ActiveUserSubscription>> {
|
||||
// NB: "status=incomplete" subscriptions can happen when a user submits credit card info,
|
||||
// but the CRC is wrong. Eg. what happened with ftx on 2022.11.18.
|
||||
//
|
||||
@@ -46,7 +45,7 @@ WHERE
|
||||
"#,
|
||||
user_token,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.fetch_all(&mut **mysql_connection)
|
||||
.await?;
|
||||
|
||||
let records = records.into_iter()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::info;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
|
||||
pub async fn connect_to_database() -> AnyhowResult<MySqlPool>{
|
||||
let db_connection_string = env_get_mysql_connection_string_or_default();
|
||||
|
||||
// NB: The managed DB's connection ceiling is far above what we use, so the binding
|
||||
// constraint is this per-pod pool. Keep `replicas * MYSQL_MAX_CONNECTIONS` (plus the
|
||||
// background jobs' small pools) comfortably under the server's `max_connections`.
|
||||
let max_connections = easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 20)?;
|
||||
let min_connections = easyenv::get_env_num("MYSQL_MIN_CONNECTIONS", 0)?;
|
||||
|
||||
// NB: Fail fast under pool saturation rather than stacking up on sqlx's 30s default — a
|
||||
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
|
||||
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
|
||||
|
||||
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
|
||||
// log the connection string (it contains credentials).
|
||||
info!(
|
||||
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
|
||||
max_connections, min_connections, acquire_timeout_seconds,
|
||||
);
|
||||
|
||||
let pool_options = MySqlPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.min_connections(min_connections)
|
||||
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
|
||||
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 5)?);
|
||||
|
||||
// TODO(bt,2024-09-17): Configure logging, etc. after we've upgraded sqlx
|
||||
|
||||
let pool = pool_options
|
||||
.connect(&db_connection_string)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+27
-1
@@ -2,6 +2,7 @@ use std::fmt::{Display, Formatter};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::http_server::session::session_checker_error::SessionCheckerError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_payload_error::AvtCookiePayloadError;
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
|
||||
use actix_http::StatusCode;
|
||||
@@ -18,7 +19,7 @@ use serde::Serialize;
|
||||
/// ```ignore
|
||||
/// pub async fn my_handler(...) -> Result<Json<MyResponse>, CommonWebError> {
|
||||
/// // sqlx errors, anyhow errors, session errors — all convert via ?
|
||||
/// let user = require_user_session(&req, ...)?;
|
||||
/// let user = require_user_session_using_connection(&req, ...)?;
|
||||
/// let data = some_db_query(&pool).await?;
|
||||
///
|
||||
/// if data.is_none() {
|
||||
@@ -316,6 +317,15 @@ impl From<serde_json::Error> for CommonWebError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RequireUserSessionError> for CommonWebError {
|
||||
fn from(value: RequireUserSessionError) -> Self {
|
||||
match value {
|
||||
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
|
||||
RequireUserSessionError::ServerError => Self::from_error(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpUserSessionPayloadError> for CommonWebError {
|
||||
fn from(err: HttpUserSessionPayloadError) -> Self {
|
||||
if err.is_server_error() {
|
||||
@@ -535,6 +545,22 @@ mod tests {
|
||||
assert!(source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_not_authorized_maps_to_401() {
|
||||
let error: CommonWebError = RequireUserSessionError::NotAuthorized.into();
|
||||
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
assert!(!error.is_server_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_server_error_wraps_cause() {
|
||||
let error: CommonWebError = RequireUserSessionError::ServerError.into();
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
assert!(error.cause().is_some());
|
||||
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_json_error_converts_to_500() {
|
||||
let bad_json = "not json at all{{{";
|
||||
|
||||
+9
-3
@@ -28,7 +28,8 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
|
||||
|
||||
@@ -113,10 +114,15 @@ pub async fn enqueue_face_fusion_workflow_handler(
|
||||
let maybe_avt_token = server_state.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+9
-3
@@ -29,7 +29,8 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
|
||||
|
||||
@@ -121,10 +122,15 @@ pub async fn enqueue_live_portrait_workflow_handler(
|
||||
let maybe_avt_token = server_state.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
-20
@@ -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,
|
||||
}
|
||||
}
|
||||
-91
@@ -1,91 +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,
|
||||
api_key,
|
||||
}))
|
||||
}
|
||||
-73
@@ -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 }))
|
||||
}
|
||||
-62
@@ -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),
|
||||
}))
|
||||
}
|
||||
-67
@@ -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;
|
||||
-92
@@ -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 }))
|
||||
}
|
||||
+7
-3
@@ -17,7 +17,7 @@ use tokens::tokens::beta_keys::BetaKeyToken;
|
||||
use crate::http_server::common_responses::pagination_page::PaginationPage;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::{require_user_session, RequireUserSessionError};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Copy, Clone, Deserialize, ToSchema)]
|
||||
@@ -108,8 +108,12 @@ pub async fn list_beta_keys_handler(
|
||||
query: Query<ListBetaKeysQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
|
||||
.await?;
|
||||
let user_session = require_user_session(&http_request, &server_state)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
let mut is_mod = user_session.can_ban_users;
|
||||
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ pub async fn redeem_beta_key_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
{
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_user_session(&http_request, &server_state).await?;
|
||||
|
||||
let rate_limiter = &server_state.redis_rate_limiters.logged_out;
|
||||
|
||||
|
||||
+6
-2
@@ -10,7 +10,7 @@ use mysql_queries::queries::characters::delete_character::delete_character;
|
||||
use mysql_queries::queries::characters::get_character_by_token_including_deleted::get_character_by_token_including_deleted;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Delete a character.
|
||||
@@ -40,7 +40,11 @@ pub async fn delete_character_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+6
-2
@@ -11,7 +11,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::update_character::update_character::{update_character, UpdateCharacterArgs};
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Edit a character's name or description.
|
||||
@@ -38,7 +38,11 @@ pub async fn edit_character_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+6
-2
@@ -16,7 +16,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// List characters for the current session.
|
||||
@@ -39,7 +39,11 @@ pub async fn list_characters_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_color_code::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_COLOR_CODE_LEN: usize = 16;
|
||||
@@ -47,7 +47,9 @@ pub async fn color_code_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
// Normalize: trim, treat empty after trim as `None`. This lets clients
|
||||
// clear by passing `""` or by omitting the field entirely.
|
||||
|
||||
+4
-2
@@ -23,7 +23,7 @@ use tokens::tokens::folders::FolderToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Set or clear the custom cover image on a folder.
|
||||
@@ -64,7 +64,9 @@ pub async fn cover_image_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
// Confirm the folder exists + is owned. Authoritative 404 source.
|
||||
get_folder_for_owner(GetFolderForOwnerArgs {
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
@@ -45,7 +45,9 @@ pub async fn create_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let name = request.name.trim().to_string();
|
||||
|
||||
|
||||
+4
-2
@@ -12,7 +12,7 @@ use mysql_queries::queries::folders::folder::soft_delete_folder::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Soft-delete a folder. Children retain their parent pointer and become
|
||||
@@ -39,7 +39,9 @@ pub async fn delete_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
|
||||
let rows_affected = soft_delete_folder(SoftDeleteFolderArgs {
|
||||
|
||||
+4
-2
@@ -16,7 +16,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Fetch a single folder owned by the logged-in user.
|
||||
@@ -42,7 +42,9 @@ pub async fn get_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let row = get_folder_for_owner(GetFolderForOwnerArgs {
|
||||
folder_token: &path.folder_token,
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "folders_all";
|
||||
@@ -48,7 +48,9 @@ pub async fn list_folders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_name::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
@@ -47,7 +47,9 @@ pub async fn rename_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let new_name = request.new_name.trim().to_string();
|
||||
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_has_star::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -44,7 +44,9 @@ pub async fn star_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
// Confirm the folder exists + is owned before issuing the update.
|
||||
// A 404 here is the authoritative "not found" signal — the update
|
||||
|
||||
+4
-2
@@ -27,7 +27,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -61,7 +61,9 @@ pub async fn bulk_add_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.media_file_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -30,7 +30,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -72,7 +72,9 @@ pub async fn bulk_move_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.media_file_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -24,7 +24,7 @@ use tokens::tokens::folders::FolderToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -56,7 +56,9 @@ pub async fn bulk_remove_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.media_file_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -32,7 +32,7 @@ use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "folder_mf";
|
||||
@@ -127,7 +127,9 @@ pub async fn list_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let folder = get_folder_for_owner(GetFolderForOwnerArgs {
|
||||
folder_token: &path.folder_token,
|
||||
|
||||
+4
-2
@@ -25,7 +25,7 @@ use mysql_queries::queries::folders::subfolder::filter_existing_owned_folder_tok
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -58,7 +58,9 @@ pub async fn bulk_add_subfolders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.subfolder_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@ use mysql_queries::queries::folders::subfolder::bulk_clear_parent_folder::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -45,7 +45,9 @@ pub async fn bulk_remove_subfolders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.subfolder_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -21,7 +21,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "subfolders";
|
||||
@@ -55,7 +55,9 @@ pub async fn list_subfolders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
|
||||
// Confirm the parent exists + is owned by the caller before listing.
|
||||
|
||||
+3
-2
@@ -8,7 +8,7 @@ use utoipa::ToSchema;
|
||||
use mysql_queries::queries::generic_inference::web::dismiss_finished_jobs_for_user::dismiss_finished_jobs_for_user;
|
||||
|
||||
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::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -40,7 +40,8 @@ pub async fn dismiss_finished_session_jobs_handler(
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection)
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut mysql_connection)
|
||||
.await?;
|
||||
|
||||
dismiss_finished_jobs_for_user(&mut mysql_connection, &user_session.user_token)
|
||||
|
||||
+6
-2
@@ -56,8 +56,12 @@ pub async fn change_media_file_animation_type_handler(
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<Json<SimpleResponse>, CommonWebError> {
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
|
||||
.await?;
|
||||
let user_session = require_user_session(&http_request, &server_state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Not authorized: {:?}", e);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let media_file_token = path.token.clone();
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+6
-2
@@ -53,8 +53,12 @@ pub async fn change_media_file_engine_category_handler(
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<Json<SimpleResponse>, CommonWebError> {
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
|
||||
.await?;
|
||||
let user_session = require_user_session(&http_request, &server_state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Not authorized: {:?}", e);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let media_file_token = path.token.clone();
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+4
-7
@@ -19,13 +19,13 @@ use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::media_files::create::insert_media_file_from_studio_scene_render::{insert_media_file_from_studio_scene_render, InsertStudioSceneRenderArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::extract_frames_from_zip::{extract_frames_from_zip, ExtractFramesError};
|
||||
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::ffmpeg_frames_to_mp4::ffmpeg_frames_to_mp4;
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const DEFAULT_FPS : u8 = 12;
|
||||
@@ -111,14 +111,11 @@ pub async fn upload_studio_shot_media_file_handler(
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
// NB: We require a moderator to upload PMX files.
|
||||
let user_session = require_user_session_extended(&http_request, &server_state.session_checker, &mut *mysql_connection)
|
||||
let user_session = require_user_session_extended_using_connection(&http_request, &server_state.session_checker, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("User session error: {:?}", e);
|
||||
match e {
|
||||
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
|
||||
_ => MediaFileUploadError::ServerError,
|
||||
}
|
||||
MediaFileUploadError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod analytics;
|
||||
pub mod api_keys;
|
||||
pub mod app_state;
|
||||
pub mod beta_keys;
|
||||
pub mod billing_fakeyou;
|
||||
|
||||
+9
-7
@@ -28,7 +28,8 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::validations::validate_model_title::validate_model_title;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -81,14 +82,15 @@ pub async fn enqueue_gptsovits_model_download_handler(
|
||||
let maybe_avt_token = server_state.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
|
||||
// so release it now rather than holding a pool slot for the rest of the handler.
|
||||
drop(mysql_connection);
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+1
-7
@@ -143,20 +143,14 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
// ==================== PIPELINE ==================== //
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
resolved_media: &resolved_media,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateImageResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
|
||||
+3
-12
@@ -25,20 +25,18 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesA
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateImageRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`finalize_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires a
|
||||
// short-lived connection only for the wallet deduction. Holding a pooled connection across the
|
||||
// external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(
|
||||
args: RunPipelineV2Args<'_>,
|
||||
) -> Result<ImagePipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
resolved_media,
|
||||
} = args;
|
||||
@@ -57,24 +55,17 @@ pub async fn run_pipeline_v2(
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
let maybe_wallet_ledger_entry_token = if cost > 0 {
|
||||
// NB: Short-lived connection — released before the external call below.
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to acquire MySQL connection for image billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
let deduction = attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
&mut billing_connection,
|
||||
mysql_connection,
|
||||
).await?;
|
||||
Some(deduction.ledger_entry_token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// NB: No pooled DB connection is held across this external generation call.
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
|
||||
|
||||
Ok(ImagePipelineResult {
|
||||
|
||||
+1
-7
@@ -200,22 +200,16 @@ pub async fn omni_gen_video_generate_handler(
|
||||
warn!("Failed to insert HTTP request debug log: {:?}", err);
|
||||
}
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map: &media_file_to_url_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateVideoResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
|
||||
+9
-23
@@ -3,6 +3,7 @@ use std::convert::TryFrom;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
@@ -27,20 +28,18 @@ use crate::state::server_state::ServerState;
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateVideoRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub media_file_to_url_map: &'a Option<HashMap<MediaFileToken, String>>,
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires
|
||||
// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled
|
||||
// connection across the external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map,
|
||||
kinovi_character_id_map,
|
||||
@@ -138,19 +137,11 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
|
||||
info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates);
|
||||
|
||||
// 3. Bill wallet (short-lived connection — released before the external call below).
|
||||
let billing = {
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
error!("Failed to acquire MySQL connection for billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
bill_wallet(user_token, cost, &mut billing_connection).await?
|
||||
};
|
||||
// 3. Bill wallet
|
||||
let billing = bill_wallet(user_token, cost, mysql_connection).await?;
|
||||
|
||||
// 4. Upload media (if draft) and generate video.
|
||||
// The entire block is wrapped so Kinovi failures trigger a refund.
|
||||
// NB: No pooled DB connection is held across this call.
|
||||
let result = upload_and_generate(
|
||||
draft_or_request,
|
||||
server_state,
|
||||
@@ -165,15 +156,10 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() {
|
||||
warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err);
|
||||
|
||||
match server_state.mysql_pool.acquire().await {
|
||||
Ok(mut refund_connection) => {
|
||||
if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
}
|
||||
}
|
||||
Err(acquire_err) => {
|
||||
error!("Failed to acquire MySQL connection to refund wallet after Kinovi v2 failure: {:?}", acquire_err);
|
||||
}
|
||||
let result = refund_wallet_after_api_failure(ledger_entry_token, mysql_connection).await;
|
||||
|
||||
if let Err(refund_err) = result {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-4
@@ -192,10 +192,6 @@ pub async fn enqueue_studio_gen2_handler(
|
||||
CommonWebError::BadInputWithSimpleMessage("invalid idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
|
||||
// so release it now rather than holding a pool slot for the rest of the handler.
|
||||
drop(mysql_connection);
|
||||
|
||||
// ==================== LOOK UP MODEL INFO ==================== //
|
||||
|
||||
// TODO(bt): CHECK DATABASE FOR TOKENS!
|
||||
|
||||
+7
-3
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use crate::http_server::common_responses::tag_info::TagInfo;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
@@ -94,10 +94,14 @@ pub async fn set_tags_for_entity_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+7
-3
@@ -11,7 +11,7 @@ use log::warn;
|
||||
use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse};
|
||||
use crate::http_server::validations::validate_passwords::validate_passwords;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::users::user::update::update_password::{update_password, UpdatePasswordArgs};
|
||||
@@ -54,10 +54,14 @@ pub async fn change_password_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+7
-3
@@ -10,7 +10,7 @@ use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::users::user::update::update_email::{update_email, UpdateEmailArgs, UpdateEmailError};
|
||||
@@ -53,10 +53,14 @@ pub async fn edit_email_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+7
-3
@@ -12,7 +12,7 @@ use artcraft_api_defs::users::edit_username::{EditUsernameRequest, EditUsernameR
|
||||
use crate::http_server::validations::is_reserved_username::is_reserved_username;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::validations::validate_username::validate_username;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::users::user::update::update_username::{update_username, UpdateUsernameArgs, UpdateUsernameError};
|
||||
@@ -63,10 +63,14 @@ pub async fn edit_username_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+12
-3
@@ -11,7 +11,8 @@ use utoipa::ToSchema;
|
||||
|
||||
use crate::configs::supported_languages_for_models::{get_canonicalized_language_tag_for_model, get_primary_language_subtag};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::by_table::model_weights::weights_types::WeightsType;
|
||||
@@ -94,10 +95,18 @@ pub async fn update_weight_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
_ => {
|
||||
warn!("get user session error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
},
|
||||
})?;
|
||||
|
||||
let weight_token = path.weight_token.clone();
|
||||
|
||||
|
||||
-2
@@ -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/...
|
||||
|
||||
|
||||
-46
@@ -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;
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::{get_user_session_by_token, SessionUserRecord};
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::{get_user_session_by_token, get_user_session_by_token_pooled_connection, SessionUserRecord};
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token_light::{get_user_session_by_token_light, SessionRecord};
|
||||
use mysql_queries::queries::users::user_subscriptions::list_active_user_subscriptions::list_active_user_subscriptions;
|
||||
use redis_caching::redis_ttl_cache::{RedisTtlCache, RedisTtlCacheConnection};
|
||||
use redis_common::redis_cache_keys::RedisCacheKeys;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Executor, MySql, MySqlConnection, MySqlPool};
|
||||
use sqlx::{Executor, MySql, MySqlPool};
|
||||
|
||||
use crate::http_server::session::lookup::user_session_extended::{UserSessionExtended, UserSessionPreferences, UserSessionPremiumPlanInfo, UserSessionRoleAndPermissions, UserSessionSubscriptionPlan, UserSessionUserDetails};
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
@@ -209,18 +209,6 @@ impl SessionChecker {
|
||||
request: &HttpRequest,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
|
||||
{
|
||||
self.maybe_get_user_session_extended_from_executor(request, &mut **mysql_connection).await
|
||||
}
|
||||
|
||||
// NB: This takes a concrete `&mut MySqlConnection` rather than a generic `Executor` because
|
||||
// the extended lookup runs two queries; it reborrows the connection for each. (A by-value
|
||||
// `E: Executor` would be consumed by the first query.)
|
||||
pub async fn maybe_get_user_session_extended_from_executor(
|
||||
&self,
|
||||
request: &HttpRequest,
|
||||
mysql_executor: &mut MySqlConnection,
|
||||
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
|
||||
{
|
||||
let session_payload= match self.cookie_manager.decode_session_payload_from_request(request)? {
|
||||
None => return Ok(None),
|
||||
@@ -229,7 +217,7 @@ impl SessionChecker {
|
||||
|
||||
// TODO: Fire both requests off simultaneously.
|
||||
let user_session = {
|
||||
match get_user_session_by_token(&mut *mysql_executor, &session_payload.session_token).await? {
|
||||
match get_user_session_by_token_pooled_connection(mysql_connection, &session_payload.session_token).await? {
|
||||
None => return Ok(None),
|
||||
Some(u) => u,
|
||||
}
|
||||
@@ -238,7 +226,7 @@ impl SessionChecker {
|
||||
// TODO: Cache this so we don't hit the database twice.
|
||||
let subscriptions =
|
||||
list_active_user_subscriptions(
|
||||
&mut *mysql_executor,
|
||||
mysql_connection,
|
||||
user_session.user_token.as_str()
|
||||
).await
|
||||
.map_err(SessionCheckerError::OtherError)?;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod require_moderator;
|
||||
pub mod require_user_session;
|
||||
pub mod require_user_session_extended;
|
||||
pub mod require_user_session_extended_using_connection;
|
||||
pub mod require_user_session_using_connection;
|
||||
|
||||
+35
-31
@@ -1,42 +1,46 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// `mysql_executor` can be any sqlx executor — pass `&server_state.mysql_pool` to grab a
|
||||
/// fresh connection, or an in-flight connection/transaction (`&mut *connection`) to reuse
|
||||
/// one the handler already holds.
|
||||
pub async fn require_user_session<'e, 'c : 'e, E>(
|
||||
#[derive(Debug)]
|
||||
pub enum RequireUserSessionError {
|
||||
ServerError,
|
||||
NotAuthorized,
|
||||
}
|
||||
|
||||
impl Display for RequireUserSessionError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ServerError => write!(f, "ServerError"),
|
||||
Self::NotAuthorized => write!(f, "NotAuthorized"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RequireUserSessionError {}
|
||||
|
||||
#[deprecated(note = "Use require_user_session_using_connection instead, which reuses an existing connection")]
|
||||
pub async fn require_user_session(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_executor: E,
|
||||
) -> Result<SessionUserRecord, CommonWebError>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_from_executor(http_request, mysql_executor)
|
||||
server_state: &ServerState,
|
||||
) -> Result<SessionUserRecord, RequireUserSessionError> {
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
.map_err(|err| {
|
||||
warn!("MySql pool error: {:?}", err);
|
||||
RequireUserSessionError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
require_user_session_using_connection(
|
||||
http_request, &server_state.session_checker, &mut mysql_connection)
|
||||
.await
|
||||
}
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::MySqlConnection;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::session::lookup::user_session_extended::UserSessionExtended;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
|
||||
/// Pass an in-flight connection (`&mut *connection`) to reuse one the handler already holds.
|
||||
/// (The extended lookup runs two queries, so it takes a concrete `&mut MySqlConnection` — which
|
||||
/// it reborrows for each — rather than a by-value `Executor` that the first query would consume.)
|
||||
pub async fn require_user_session_extended(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_executor: &mut MySqlConnection,
|
||||
) -> Result<UserSessionExtended, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_extended_from_executor(http_request, mysql_executor)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.role.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
use std::error::Error;
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
|
||||
|
||||
use crate::http_server::session::lookup::user_session_extended::UserSessionExtended;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
|
||||
pub async fn require_user_session_extended_using_connection(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<UserSessionExtended, RequireUserSessionError> {
|
||||
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_extended_from_connection(&http_request, mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
RequireUserSessionError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(RequireUserSessionError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.role.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(RequireUserSessionError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord;
|
||||
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
|
||||
pub async fn require_user_session_using_connection(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<SessionUserRecord, RequireUserSessionError> {
|
||||
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_from_connection(http_request, mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
RequireUserSessionError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(RequireUserSessionError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(RequireUserSessionError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use rand::Rng;
|
||||
|
||||
/// 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.
|
||||
pub fn generate_api_key() -> String {
|
||||
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);
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn starts_with_prefix() {
|
||||
assert!(generate_api_key().starts_with("artcraft_api_"));
|
||||
assert!(generate_api_key().starts_with(API_KEY_PREFIX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_expected_total_length() {
|
||||
assert_eq!(generate_api_key().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_suffix_is_crockford_lowercase() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.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.contains(forbidden), "key must not contain {:?}", forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscores_only_appear_in_the_prefix() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.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.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;
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ ios = { level = "full", notes = "" }
|
||||
|
||||
[build-dependencies]
|
||||
#tauri-plugin = { workspace = true, features = ["build"] }
|
||||
tauri-plugin = { version = "2.6.2", features = ["build"] }
|
||||
tauri-plugin = { version = "2.2.0", features = ["build"] }
|
||||
#schemars = { workspace = true }
|
||||
schemars = { version = "0.8" }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -91,7 +91,7 @@ sqlx-sqlite = { version = "0.7.3", default-features = false, features = ["any",
|
||||
stable_deref_trait = { version = "1.2.0" }
|
||||
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
|
||||
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
|
||||
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
|
||||
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
|
||||
time = { version = "0.3.36", features = ["local-offset", "macros", "serde-well-known"] }
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.16", features = ["fs"] }
|
||||
@@ -171,8 +171,8 @@ stable_deref_trait = { version = "1.2.0" }
|
||||
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
|
||||
syn-dff4ba8e3ae991db = { package = "syn", version = "1.0.109", features = ["extra-traits", "fold", "full", "visit-mut"] }
|
||||
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
|
||||
tauri-build = { version = "2.6.2" }
|
||||
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
|
||||
tauri-build = { version = "2.1.1" }
|
||||
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
|
||||
time-macros = { version = "0.2.18", default-features = false, features = ["formatting", "parsing", "serde"] }
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.16", features = ["fs"] }
|
||||
|
||||
+5
-11
@@ -2,18 +2,12 @@ import { useCallback, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCheck, faCopy } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { toast } from "../toast/toast";
|
||||
|
||||
// Copies a prompt to the clipboard, with copy→check feedback. Sits at the
|
||||
// right of a row's prompt line. Shared by the completed / pending / failed
|
||||
// rows. stopPropagation keeps a tap from also triggering the row's onClick.
|
||||
// Hosts surface success/failure feedback (e.g. a toast) via `onCopyResult`.
|
||||
export function CopyPromptButton({
|
||||
text,
|
||||
onCopyResult,
|
||||
}: {
|
||||
text: string;
|
||||
onCopyResult?: (success: boolean) => void;
|
||||
}) {
|
||||
export function CopyPromptButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
@@ -21,14 +15,14 @@ export function CopyPromptButton({
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
onCopyResult?.(true);
|
||||
toast.success("Prompt copied");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
onCopyResult?.(false);
|
||||
toast.error("Unable to copy prompt");
|
||||
}
|
||||
},
|
||||
[text, onCopyResult],
|
||||
[text],
|
||||
);
|
||||
|
||||
return (
|
||||
-10
@@ -196,16 +196,6 @@ export function CreateMediaPageShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom fade behind the floating prompt box so it stays legible
|
||||
over the feed. Sits under the prompt box (z-30). */}
|
||||
{hasContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
|
||||
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{promptBox}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+34
-8
@@ -1,7 +1,13 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faCircleExclamation,
|
||||
faSpinnerThird,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
|
||||
export interface FailedCardProps {
|
||||
id: string;
|
||||
@@ -14,8 +20,10 @@ export interface FailedCardProps {
|
||||
// at low opacity so failed cards still hint at what the user was trying to
|
||||
// generate.
|
||||
refImageUrl?: string;
|
||||
/** Inline action (e.g. a labeled Recreate button) next to Dismiss. */
|
||||
recreateSlot?: ReactNode;
|
||||
// Prompt token + media class enable the "Recreate" action. Without a prompt
|
||||
// token there's nothing to seed the create page with, so the button hides.
|
||||
promptToken?: string;
|
||||
recreateMediaClass: "image" | "video";
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -27,10 +35,15 @@ export const FailedCard = memo(function FailedCard({
|
||||
modelId,
|
||||
modelLabel,
|
||||
refImageUrl,
|
||||
recreateSlot,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
onDismiss,
|
||||
}: FailedCardProps) {
|
||||
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
|
||||
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-red-500/10">
|
||||
@@ -56,7 +69,20 @@ export const FailedCard = memo(function FailedCard({
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
{recreateSlot}
|
||||
{promptToken && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={isRecreating ? "animate-spin" : ""}
|
||||
/>
|
||||
Recreate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(id)}
|
||||
+35
-27
@@ -1,25 +1,16 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faCircleExclamation,
|
||||
faSpinnerThird,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { CopyPromptButton } from "./CopyPromptButton";
|
||||
|
||||
export interface FailedRowProps {
|
||||
id: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
// First user-supplied still image, if any. Rendered behind the error overlay
|
||||
// at low opacity so failed rows still hint at what the user was trying to
|
||||
// generate.
|
||||
refImageUrl?: string;
|
||||
/** Inline action (e.g. a Recreate button) after the failure reason. */
|
||||
recreateSlot?: ReactNode;
|
||||
onDismiss: (id: string) => void;
|
||||
onCopyPromptResult?: (success: boolean) => void;
|
||||
}
|
||||
import type { FailedCardProps } from "./FailedCard";
|
||||
|
||||
export const FailedRow = memo(function FailedRow({
|
||||
id,
|
||||
@@ -29,11 +20,15 @@ export const FailedRow = memo(function FailedRow({
|
||||
modelId,
|
||||
modelLabel,
|
||||
refImageUrl,
|
||||
recreateSlot,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
onDismiss,
|
||||
onCopyPromptResult,
|
||||
}: FailedRowProps) {
|
||||
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
|
||||
}: FailedCardProps) {
|
||||
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg px-2.5 py-2">
|
||||
@@ -59,10 +54,23 @@ export const FailedRow = memo(function FailedRow({
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-red-400">
|
||||
{failureReason || "Generation failed"}
|
||||
</p>
|
||||
{prompt && (
|
||||
<CopyPromptButton text={prompt} onCopyResult={onCopyPromptResult} />
|
||||
{prompt && <CopyPromptButton text={prompt} />}
|
||||
{promptToken && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recreateSlot}
|
||||
</div>
|
||||
{prompt && (
|
||||
<p className="mt-0.5 truncate text-xs text-white/45">{prompt}</p>
|
||||
+92
-26
@@ -1,12 +1,17 @@
|
||||
import { memo, useCallback, useState, type ReactNode } from "react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube, faImage, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryThumbnail } from "./GalleryThumbnail";
|
||||
import type { GalleryItem } from "./types";
|
||||
import { useGalleryItemActions } from "./useGalleryItemActions";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
// ── Persistent aspect ratio cache ─────────────────────────────────────────
|
||||
|
||||
@@ -53,36 +58,43 @@ export const aspectRatioCache = loadCache();
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GalleryCardProps {
|
||||
interface GalleryCardProps {
|
||||
item: GalleryItem;
|
||||
onClick: (item: GalleryItem) => void;
|
||||
// "auto" = dynamic aspect ratio from the loaded image (masonry layouts).
|
||||
// "square" = fixed 1:1; skips the ratio measurement path (uniform grids).
|
||||
shape?: "auto" | "square";
|
||||
/** Hover-revealed quick-action cluster (recreate / share / download …). */
|
||||
actionsSlot?: ReactNode;
|
||||
// When true, image cards show a "Make Video" quick action that seeds the
|
||||
// create-video page with the image as a reference. Enabled on the create
|
||||
// image page only.
|
||||
enableMakeVideo?: boolean;
|
||||
}
|
||||
|
||||
export const GalleryCard = memo(function GalleryCard({
|
||||
item,
|
||||
onClick,
|
||||
shape = "auto",
|
||||
actionsSlot,
|
||||
enableMakeVideo = false,
|
||||
}: GalleryCardProps) {
|
||||
const isSquare = shape === "square";
|
||||
const cached = aspectRatioCache.get(item.id);
|
||||
const [ratio, setRatio] = useState<number | undefined>(cached);
|
||||
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const mediaIcon = isVideo ? faVideo : is3D ? faCube : faImage;
|
||||
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : "Image";
|
||||
const modelDisplayName = item.modelId
|
||||
? getModelDisplayName(item.modelId)
|
||||
: null;
|
||||
const modelIconPath = item.modelId
|
||||
? getCreatorIconPathForModelId(item.modelId)
|
||||
: null;
|
||||
const {
|
||||
mediaIcon,
|
||||
mediaLabel,
|
||||
modelDisplayName,
|
||||
modelIconPath,
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = useGalleryItemActions(item, { enableMakeVideo });
|
||||
|
||||
const displayRatio = ratio ? Math.min(ratio, MAX_RATIO) : 1;
|
||||
|
||||
@@ -139,7 +151,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
<GalleryThumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.label}
|
||||
isVideo={isVideo}
|
||||
isVideo={item.mediaClass === "video"}
|
||||
fallbackIcon={mediaIcon}
|
||||
onLoad={measureRatio}
|
||||
/>
|
||||
@@ -164,11 +176,65 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionsSlot && (
|
||||
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
|
||||
{actionsSlot}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{item.fullImage && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
aria-label="Download"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
+117
-36
@@ -1,50 +1,47 @@
|
||||
import { memo, useCallback, type ReactNode } from "react";
|
||||
import { memo, useCallback } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube, faImage, faPlay, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faPlay,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryThumbnail } from "./GalleryThumbnail";
|
||||
import { CopyPromptButton } from "./CopyPromptButton";
|
||||
import { formatTimeAgo } from "./format-time-ago";
|
||||
import type { GalleryItem } from "./types";
|
||||
import {
|
||||
useGalleryItemActions,
|
||||
type GalleryItemActions,
|
||||
} from "./useGalleryItemActions";
|
||||
import { formatTimeAgo } from "../../lib/format-time-ago";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
export interface GalleryRowProps {
|
||||
interface GalleryRowProps {
|
||||
item: GalleryItem;
|
||||
onClick: (item: GalleryItem) => void;
|
||||
enableMakeVideo?: boolean;
|
||||
// Prompt + model resolved from the prompt record by the list. Fall back to
|
||||
// the item's own fields when the prompt hasn't loaded (or has no text).
|
||||
title?: string;
|
||||
modelId?: string;
|
||||
// The prompt record is still resolving — show a skeleton in place of the title.
|
||||
loading?: boolean;
|
||||
/** Hover-revealed quick-action cluster (recreate / share / download …). */
|
||||
actionsSlot?: ReactNode;
|
||||
onCopyPromptResult?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
export const GalleryRow = memo(function GalleryRow({
|
||||
item,
|
||||
onClick,
|
||||
enableMakeVideo = false,
|
||||
title,
|
||||
modelId,
|
||||
loading = false,
|
||||
actionsSlot,
|
||||
onCopyPromptResult,
|
||||
}: GalleryRowProps) {
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const mediaIcon = isVideo ? faVideo : is3D ? faCube : faImage;
|
||||
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : "Image";
|
||||
|
||||
const effectiveModelId = modelId ?? item.modelId;
|
||||
const modelDisplayName = effectiveModelId
|
||||
? getModelDisplayName(effectiveModelId)
|
||||
: null;
|
||||
const modelIconPath = effectiveModelId
|
||||
? getCreatorIconPathForModelId(effectiveModelId)
|
||||
: null;
|
||||
const actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
|
||||
const { isVideo, mediaIcon, mediaLabel, modelDisplayName, modelIconPath } =
|
||||
actions;
|
||||
|
||||
const handleRowClick = useCallback(() => onClick(item), [item, onClick]);
|
||||
const handleRowKeyDown = useCallback(
|
||||
@@ -100,12 +97,7 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
<p className="line-clamp-3 min-w-0 flex-1 text-sm leading-snug text-white/90">
|
||||
{title || item.label}
|
||||
</p>
|
||||
{title && (
|
||||
<CopyPromptButton
|
||||
text={title}
|
||||
onCopyResult={onCopyPromptResult}
|
||||
/>
|
||||
)}
|
||||
{title && <CopyPromptButton text={title} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-white/45">
|
||||
@@ -129,11 +121,12 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
{/* Quick actions, right after the media type. Always visible on
|
||||
mobile (no hover); hover-revealed on desktop. The prompt above
|
||||
spans the full row width either way. */}
|
||||
{actionsSlot && (
|
||||
<div className="flex shrink-0 items-center gap-0.5 sm:ms-1.5 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100">
|
||||
{actionsSlot}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-0.5 sm:ms-1.5 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100">
|
||||
<GalleryRowActions
|
||||
actions={actions}
|
||||
hasDownload={!!item.fullImage}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-white/40">
|
||||
{timeAgo}
|
||||
</span>
|
||||
@@ -142,3 +135,91 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Recreate / make-video / share / download buttons, shared by the desktop
|
||||
// (hover) and mobile (inline) clusters. Each handler stops propagation so taps
|
||||
// don't also open the lightbox.
|
||||
function GalleryRowActions({
|
||||
actions,
|
||||
hasDownload,
|
||||
}: {
|
||||
actions: GalleryItemActions;
|
||||
hasDownload: boolean;
|
||||
}) {
|
||||
const {
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = actions;
|
||||
|
||||
const buttonClass =
|
||||
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<>
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{hasDownload && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label="Download"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user