Compare commits

..
Author SHA1 Message Date
Brandon Thomas 1573b69780 cleanup usage of require_moderator 2026-06-13 05:25:57 -04:00
Brandon Thomas 2a2e39a1ac update arguments 2026-06-13 05:03:51 -04:00
185 changed files with 3925 additions and 7510 deletions
@@ -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"
}
@@ -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"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4"
}
@@ -1,124 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c"
}
@@ -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
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -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;
+1 -1
View File
@@ -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,
}))
}
@@ -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;
@@ -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,
@@ -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{{{";
@@ -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 ==================== //
@@ -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 ==================== //
@@ -1,20 +0,0 @@
use artcraft_api_defs::api_keys::common::ApiKeyInfo;
use mysql_queries::queries::api_keys::api_key_row::ApiKeyRow;
/// Convert a data-access `ApiKeyRow` into the public `ApiKeyInfo` wire shape.
/// The row only ever carries the truncated key prefix (never the full secret),
/// so this is a straight field map.
pub fn api_key_row_to_info(row: ApiKeyRow) -> ApiKeyInfo {
ApiKeyInfo {
token: row.token,
truncated_api_key: row.api_key_prefix,
name: row.name,
maybe_description: row.maybe_description,
owner_user_token: row.owner_user_token,
ip_address_creation: row.ip_address_creation,
ip_address_update: row.ip_address_update,
created_at: row.created_at,
updated_at: row.updated_at,
maybe_deleted_at: row.maybe_deleted_at,
}
}
@@ -1,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,
}))
}
@@ -1,73 +0,0 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
use artcraft_api_defs::api_keys::delete_api_key::DeleteApiKeySuccessResponse;
use mysql_queries::queries::api_keys::delete_api_key::{delete_api_key, DeleteApiKeyArgs};
use mysql_queries::queries::api_keys::get_api_key_by_token::{
get_api_key_by_token, GetApiKeyByTokenArgs,
};
use tokens::tokens::api_keys::ApiKeyToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Soft-delete an API key by `token`, scoped to the logged-in user.
#[utoipa::path(
delete,
tag = "API Keys",
path = "/v1/api_keys/{api_key_token}",
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
responses(
(status = 200, body = DeleteApiKeySuccessResponse),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn delete_api_key_handler(
http_request: HttpRequest,
path: Path<ApiKeyPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<DeleteApiKeySuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
// The by-token delete query isn't owner-scoped, so first look the key up and
// confirm ownership. Return NotFound for missing OR other-owned keys, so we
// never leak the existence of another user's key.
let row = get_api_key_by_token(GetApiKeyByTokenArgs {
token: &path.api_key_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("get_api_key_by_token failed: {:?}", err);
CommonWebError::from_error(err)
})?
.filter(|row| row.owner_user_token == user_session.user_token)
.ok_or(CommonWebError::NotFound)?;
// Only issue the delete if the key is still live. If it's already
// soft-deleted the desired end state already holds, so treat it as success.
if row.maybe_deleted_at.is_none() {
delete_api_key(DeleteApiKeyArgs {
token: &path.api_key_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("delete_api_key failed: {:?}", err);
CommonWebError::from_error(err)
})?;
}
Ok(Json(DeleteApiKeySuccessResponse { success: true }))
}
@@ -1,62 +0,0 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
use artcraft_api_defs::api_keys::get_api_key::GetApiKeySuccessResponse;
use mysql_queries::queries::api_keys::get_api_key_by_token::{
get_api_key_by_token, GetApiKeyByTokenArgs,
};
use tokens::tokens::api_keys::ApiKeyToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Fetch a single API key by its `token`, scoped to the logged-in user.
/// `NotFound` if the key doesn't exist or belongs to a different user (so we
/// don't leak the existence of others' keys). Never returns the full secret.
#[utoipa::path(
get,
tag = "API Keys",
path = "/v1/api_keys/{api_key_token}",
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
responses(
(status = 200, body = GetApiKeySuccessResponse),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn get_api_key_handler(
http_request: HttpRequest,
path: Path<ApiKeyPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetApiKeySuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let row = get_api_key_by_token(GetApiKeyByTokenArgs {
token: &path.api_key_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("get_api_key_by_token failed: {:?}", err);
CommonWebError::from_error(err)
})?
.filter(|row| row.owner_user_token == user_session.user_token)
.ok_or(CommonWebError::NotFound)?;
Ok(Json(GetApiKeySuccessResponse {
success: true,
api_key: api_key_row_to_info(row),
}))
}
@@ -1,67 +0,0 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::api_keys::list_api_keys::{ListApiKeysQueryParams, ListApiKeysSuccessResponse};
use mysql_queries::queries::api_keys::list_api_keys_for_user::{
list_api_keys_for_user, ListApiKeysForUserArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
/// List the logged-in user's API keys, newest first, **including
/// soft-deleted** keys. Paginated via `limit`/`offset`. Never returns the full
/// secret — each entry carries only the truncated key prefix.
#[utoipa::path(
get,
tag = "API Keys",
path = "/v1/api_keys/list",
params(ListApiKeysQueryParams),
responses(
(status = 200, body = ListApiKeysSuccessResponse),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_api_keys_handler(
http_request: HttpRequest,
query: Query<ListApiKeysQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListApiKeysSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let offset = query.offset.unwrap_or(0);
let rows = list_api_keys_for_user(ListApiKeysForUserArgs {
owner_user_token: &user_session.user_token,
limit,
offset,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_api_keys_for_user failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let api_keys = rows.into_iter().map(api_key_row_to_info).collect();
Ok(Json(ListApiKeysSuccessResponse {
success: true,
api_keys,
}))
}
@@ -1,6 +0,0 @@
pub mod api_key_info_conversion;
pub mod create_api_key_handler;
pub mod delete_api_key_handler;
pub mod get_api_key_handler;
pub mod list_api_keys_handler;
pub mod update_api_key_handler;
@@ -1,92 +0,0 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
use artcraft_api_defs::api_keys::update_api_key::{UpdateApiKeyRequest, UpdateApiKeySuccessResponse};
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::api_keys::get_api_key_by_token::{
get_api_key_by_token, GetApiKeyByTokenArgs,
};
use mysql_queries::queries::api_keys::update_api_key::{update_api_key, UpdateApiKeyArgs};
use tokens::tokens::api_keys::ApiKeyToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_DESCRIPTION_LEN: usize = 512;
/// Update an API key's description (also refreshing its update IP), scoped to
/// the logged-in user. `NotFound` if the key doesn't exist, belongs to another
/// user, or has been deleted.
#[utoipa::path(
put,
tag = "API Keys",
path = "/v1/api_keys/{api_key_token}",
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
request_body = UpdateApiKeyRequest,
responses(
(status = 200, body = UpdateApiKeySuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn update_api_key_handler(
http_request: HttpRequest,
path: Path<ApiKeyPathInfo>,
request: Json<UpdateApiKeyRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<UpdateApiKeySuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
if let Some(description) = &request.maybe_description {
if description.len() > MAX_DESCRIPTION_LEN {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("description too long (max {} chars)", MAX_DESCRIPTION_LEN),
));
}
}
// Confirm the key exists and is owned by this user before updating (the
// update query isn't owner-scoped). NotFound for missing/other-owned keys.
let existing = get_api_key_by_token(GetApiKeyByTokenArgs {
token: &path.api_key_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("get_api_key_by_token failed: {:?}", err);
CommonWebError::from_error(err)
})?
.filter(|row| row.owner_user_token == user_session.user_token)
.ok_or(CommonWebError::NotFound)?;
if existing.maybe_deleted_at.is_some() {
return Err(CommonWebError::NotFound);
}
let ip_address = get_request_ip(&http_request);
update_api_key(UpdateApiKeyArgs {
token: &path.api_key_token,
maybe_description: request.maybe_description.as_deref(),
ip_address: &ip_address,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("update_api_key failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(UpdateApiKeySuccessResponse { success: true }))
}
@@ -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;
@@ -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;
@@ -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();
@@ -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();
@@ -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;
@@ -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.
@@ -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 {
@@ -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();
@@ -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 {
@@ -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,
@@ -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);
@@ -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();
@@ -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
@@ -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(
@@ -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(
@@ -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(
@@ -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,
@@ -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(
@@ -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(
@@ -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.
@@ -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)
@@ -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();
@@ -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();
@@ -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;
@@ -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 ==================== //
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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);
}
}
}
@@ -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!
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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();
@@ -1,5 +1,4 @@
use crate::http_server::routes::application_routes::analytics_routes::add_analytics_routes;
use crate::http_server::routes::application_routes::api_keys_routes::add_api_keys_routes;
use crate::http_server::routes::application_routes::billing_fakeyou_routes::add_billing_fakeyou_routes;
use crate::http_server::routes::application_routes::character_routes::add_character_routes;
use crate::http_server::routes::application_routes::comments_routes::add_comments_routes;
@@ -58,7 +57,6 @@ where
// Media files routes
app = add_media_file_routes(app); // /v1/media_files/...
app = add_folders_routes(app); // /v1/folders/...
app = add_api_keys_routes(app); // /v1/api_keys/...
app = add_featured_item_routes(app); // /v1/featured_item/...
app = add_prompts_routes(app); // /v1/prompts/...
@@ -1,46 +0,0 @@
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::api_keys::create_api_key_handler::create_api_key_handler;
use crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler;
use crate::http_server::endpoints::api_keys::get_api_key_handler::get_api_key_handler;
use crate::http_server::endpoints::api_keys::list_api_keys_handler::list_api_keys_handler;
use crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler;
pub fn add_api_keys_routes<T, B>(app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
app.service(
web::scope("/v1/api_keys")
// NB: Static routes are registered BEFORE the dynamic `/{api_key_token}`
// route so they take precedence. API key tokens are always `api_key_…`,
// so they can never collide with these literals anyway.
.service(
web::resource("/create")
.route(web::post().to(create_api_key_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/list")
.route(web::get().to(list_api_keys_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{api_key_token}")
.route(web::get().to(get_api_key_handler))
.route(web::delete().to(delete_api_key_handler))
.route(web::put().to(update_api_key_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
}
@@ -1,5 +1,4 @@
mod analytics_routes;
mod api_keys_routes;
mod billing_fakeyou_routes;
mod character_routes;
mod comments_routes;
@@ -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;
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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
View File
@@ -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"] }
@@ -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 (
@@ -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>
@@ -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)}
@@ -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>
@@ -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>
);
@@ -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