Compare commits

..
Author SHA1 Message Date
Brandon Thomas ede5c1e6a6 cleanup require_moderator 2026-06-13 04:51:00 -04:00
Brandon Thomas e6b59a7a7e cleanup require_moderator 2026-06-13 04:45:26 -04:00
Brandon Thomas 9a6e613d91 fix deprecated method 2026-06-13 04:31:30 -04:00
167 changed files with 3579 additions and 5661 deletions
@@ -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()
@@ -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)
@@ -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 ==================== //
@@ -14,7 +14,7 @@ use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
use crate::state::server_state::ServerState;
const MAXIMUM_KEYS : u32 = 100;
@@ -60,7 +60,12 @@ pub async fn create_beta_keys_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<HttpResponse, CommonWebError>
{
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => CommonWebError::from_error(err),
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
let mut maybe_referrer_user_token = None;
@@ -14,7 +14,7 @@ use tokens::tokens::w2l_results::W2lResultToken;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
use crate::state::server_state::ServerState;
/// For the URL PathInfo
@@ -58,7 +58,12 @@ pub async fn edit_beta_key_distributed_flag_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<HttpResponse, CommonWebError>
{
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => CommonWebError::from_error(err),
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
edit_beta_key_distributed_flag(&path.token, request.is_distributed, &server_state.mysql_pool)
.await
@@ -13,7 +13,7 @@ use tokens::tokens::beta_keys::BetaKeyToken;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
use crate::state::server_state::ServerState;
/// For the URL PathInfo
@@ -57,7 +57,12 @@ pub async fn edit_beta_key_note_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<HttpResponse, CommonWebError>
{
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => CommonWebError::from_error(err),
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
let maybe_note = request.note.as_deref()
.map(|note| note.trim())
@@ -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();
@@ -23,8 +23,7 @@ use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::upload_pmx::extract_and_upload_pmx_files::{extract_and_upload_pmx_files, PmxError};
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
use crate::state::server_state::ServerState;
/// Form-multipart request fields.
@@ -116,11 +115,11 @@ pub async fn upload_pmx_media_file_handler(
// ==================== READ SESSION ==================== //
// NB: We require a moderator to upload PMX files.
let user_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection)
let user_session = require_moderator(&http_request, &server_state, &mut *mysql_connection)
.await
.map_err(|err| match err {
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
_ => MediaFileUploadError::ServerError,
RequireModeratorError::ServerError => MediaFileUploadError::ServerError,
RequireModeratorError::NotAuthorized => MediaFileUploadError::NotAuthorized,
})?;
let maybe_avt_token = server_state
@@ -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
@@ -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 ==================== //
@@ -35,7 +35,12 @@ pub async fn moderation_send_alert_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModerationSendAlertResponse>, CommonWebError> {
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let ip_address = get_request_ip(&http_request);
@@ -72,7 +72,10 @@ pub async fn moderation_list_debug_logs_for_token_handler(
query: web::Query<ListDebugLogsQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListDebugLogsSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
@@ -25,7 +25,7 @@ use tokens::tokens::users::UserToken;
use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
use crate::state::server_state::ServerState;
/// For the URL PathInfo
@@ -60,7 +60,12 @@ pub async fn moderator_get_token_info_handler(
server_state: web::Data<Arc<ServerState>>
) -> Result<HttpResponse, CommonWebError> {
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|err| match err {
RequireModeratorError::ServerError => CommonWebError::from_error(err),
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
let token = path.token.trim();
let maybe_result = get_entity_from_token(&server_state.mysql_pool, &path.token)
@@ -90,7 +90,10 @@ pub async fn moderation_get_job_by_token_handler(
path: web::Path<GetJobByTokenPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetJobByTokenSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
@@ -36,7 +36,9 @@ pub async fn list_user_jobs_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListUserJobsResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let results = list_user_jobs_for_moderation(&path.user_token, &server_state.mysql_pool)
.await
@@ -74,7 +74,10 @@ pub async fn moderator_list_staff_audit_logs_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListStaffAuditLogsSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let limit = query.limit
.unwrap_or(DEFAULT_LIMIT)
@@ -36,7 +36,9 @@ pub async fn moderator_list_subscribing_users_by_signup_date_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListSubscribingUsersBySignupDateResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let result = list_subscribing_users_by_signup_date_for_moderation(
ListSubscribingUsersBySignupDateArgs {
@@ -36,7 +36,9 @@ pub async fn moderator_list_users_by_signup_date_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUsersBySignupDateResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let result = list_users_by_signup_date_for_moderation(
ListUsersBySignupDateArgs {
@@ -33,7 +33,9 @@ pub async fn moderator_user_lookup_by_stripe_customer_id_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorUserLookupByStripeCustomerIdResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let stripe_customer_id = request.stripe_customer_id.trim();
@@ -40,7 +40,9 @@ pub async fn moderator_user_lookup_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorUserLookupSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let search = request.search.trim();
@@ -59,7 +59,10 @@ pub async fn moderation_ban_user_handler(
) -> Result<Json<ModerationBanUserSuccessResponse>, CommonWebError> {
// 1. Require moderator with ban permissions.
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
if !user_session.can_ban_users {
warn!("User {} is not allowed to ban users", user_session.user_token.as_str());
@@ -69,7 +69,10 @@ pub async fn moderator_change_user_email_handler(
) -> Result<Json<ModeratorChangeUserEmailSuccessResponse>, CommonWebError> {
// 1. Require moderator.
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
// 2. Normalize + validate the new email.
let new_email = request.new_email.trim().to_lowercase();
@@ -101,7 +101,10 @@ pub async fn moderator_list_email_address_changes_for_user_handler(
) -> Result<Json<ModeratorListUserEmailChangesSuccessResponse>, CommonWebError> {
// 1. Require moderator.
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
// 2. Resolve username → user_token. Not-found → 404; everything else → 500.
let username = path.username.trim().to_lowercase();
@@ -87,7 +87,10 @@ pub async fn moderator_edit_user_feature_flags_handler(
redis_pool: Data<r2d2::Pool<Client>>,
) -> Result<HttpResponse, CommonWebError> {
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let username_or_token = path.username_or_token.trim();
@@ -47,7 +47,10 @@ pub async fn moderator_list_all_available_user_feature_flags_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let feature_flags: Vec<FeatureFlagDescriptor> = UserFeatureFlag::all_variants()
.into_iter()
@@ -54,7 +54,10 @@ pub async fn moderator_list_user_feature_flags_handler(
path: Path<ListUserFeatureFlagsPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let username_or_token = path.username_or_token.trim();
@@ -40,7 +40,10 @@ pub async fn moderator_list_global_user_referrals_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListGlobalUserReferralsSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let limit = query.limit
.unwrap_or(DEFAULT_LIMIT)
@@ -45,7 +45,10 @@ pub async fn moderator_list_user_referrals_for_user_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListUserReferralsForUserSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let limit = query.limit
.unwrap_or(DEFAULT_LIMIT)
@@ -79,7 +79,10 @@ pub async fn moderator_list_user_session_impersonation_requests_for_user_handler
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListUserImpersonationRequestsSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let limit = query.limit
.unwrap_or(DEFAULT_LIMIT)
@@ -73,7 +73,10 @@ pub async fn moderator_list_user_session_impersonation_requests_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListAllImpersonationRequestsSuccessResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let limit = query.limit
.unwrap_or(DEFAULT_LIMIT)
@@ -69,7 +69,10 @@ pub async fn moderator_user_session_impersonation_request_handler(
) -> Result<Json<ModerationImpersonateSuccessResponse>, CommonWebError> {
// 1. Require moderator session.
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
if user_session.is_banned {
warn!("Banned moderator tried to impersonate: {}", user_session.user_token.as_str());
@@ -43,7 +43,9 @@ pub async fn moderator_get_user_stripe_customer_ids_handler(
.acquire()
.await?;
let _moderator_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
let _moderator_session = require_moderator(&http_request, &server_state, &mut *mysql_connection)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let customer_links = list_user_stripe_customer_links_for_user(&path.user_token, &mut mysql_connection)
.await
@@ -36,7 +36,9 @@ pub async fn list_wallet_ledger_entries_by_wallet_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListWalletLedgerEntriesByWalletResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let results = list_wallet_ledger_entries_by_wallet(&path.wallet_token, &server_state.mysql_pool)
.await
@@ -36,7 +36,9 @@ pub async fn moderator_get_wallet_ledger_entry_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorGetWalletLedgerEntryResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let maybe_entry = get_wallet_ledger_entry_for_moderation(&path.wallet_ledger_entry_token, &server_state.mysql_pool)
.await
@@ -36,7 +36,9 @@ pub async fn list_user_wallets_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListUserWalletsResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let results = list_user_wallets_for_moderation(&path.user_token, &server_state.mysql_pool)
.await
@@ -46,7 +46,12 @@ pub async fn moderator_add_banked_balance_to_wallet_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorAddBankedBalanceToWalletResponse>, CommonWebError> {
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
let ip_address = get_request_ip(&http_request);
@@ -37,7 +37,9 @@ pub async fn moderator_create_wallet_for_user_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorCreateWalletForUserResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let user_token = request.user_token.as_ref()
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("user_token is required".to_string()))?;
@@ -36,7 +36,9 @@ pub async fn moderator_get_wallet_handler(
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorGetWalletResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let maybe_wallet = get_wallet_for_moderation(&path.wallet_token, &server_state.mysql_pool)
.await
@@ -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();
@@ -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,41 +1,61 @@
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::state::server_state::ServerState;
#[derive(Debug)]
pub enum RequireModeratorError {
ServerError,
NotAuthorized,
}
impl Display for RequireModeratorError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ServerError => write!(f, "ServerError"),
Self::NotAuthorized => write!(f, "NotAuthorized"),
}
}
}
impl Error for RequireModeratorError {}
/// `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_moderator<'e, 'c : 'e, E>(
http_request: &HttpRequest,
session_checker: &SessionChecker,
server_state: &ServerState,
mysql_executor: E,
) -> Result<SessionUserRecord, CommonWebError>
) -> Result<SessionUserRecord, RequireModeratorError>
where E: 'e + Executor<'c, Database = MySql>
{
let maybe_user_session = session_checker
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_executor(http_request, mysql_executor)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::from_error(e)
RequireModeratorError::ServerError
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => {
warn!("not logged in");
return Err(CommonWebError::NotAuthorized);
return Err(RequireModeratorError::NotAuthorized);
}
};
if !user_session.is_mod() {
warn!("user is not a moderator: {:?}", user_session.user_token.as_str());
return Err(CommonWebError::NotAuthorized);
return Err(RequireModeratorError::NotAuthorized);
}
Ok(user_session)
@@ -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)
}
@@ -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>
)}
</>
);
}
@@ -1,2 +1,182 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { GalleryThumbnail } from "@storyteller/ui-generation-list";
import { useCallback, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
// video thumbnails 404 until the render job finishes (so we retry on a timer
// and on tab refocus), and broken image URLs fall back to a placeholder.
// ── Shared visibility listener for video thumbnail retries ────────────────
const visibilityCallbacks = new Set<() => void>();
function onTabVisible(cb: () => void) {
visibilityCallbacks.add(cb);
if (visibilityCallbacks.size === 1) {
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
}
return () => {
visibilityCallbacks.delete(cb);
if (visibilityCallbacks.size === 0) {
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
}
};
}
function fireVisibilityCallbacks() {
if (document.hidden) return;
visibilityCallbacks.forEach((cb) => cb());
}
// ── Retry constants ────────────────────────────────────────────────────────
const MAX_RETRIES = 20;
const RETRY_INTERVAL = 5000;
// ── Component ──────────────────────────────────────────────────────────────
interface GalleryThumbnailProps {
thumbnail: string | null;
alt: string;
isVideo: boolean;
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
fallbackIcon: IconDefinition;
// Classes for the <img> element (sizing / object-fit).
imgClassName?: string;
fallbackIconClassName?: string;
// The "Loading thumbnail…" caption is too large for small list rows.
showRetryLabel?: boolean;
// Called on successful load — the card uses it to measure aspect ratio.
onLoad?: (e: React.SyntheticEvent<HTMLImageElement>) => void;
}
export function GalleryThumbnail({
thumbnail,
alt,
isVideo,
fallbackIcon,
imgClassName = "block h-full w-full object-cover",
fallbackIconClassName = "text-xl text-white/20",
showRetryLabel = true,
onLoad,
}: GalleryThumbnailProps) {
// "retrying" flips to true only after the first error, so videos with ready
// thumbnails render the normal <img> path with zero overhead. While retrying,
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
const [retrying, setRetrying] = useState(false);
const retryImgRef = useRef<HTMLImageElement>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const retryCountRef = useRef(0);
const kickRetry = useCallback(() => {
if (!retryImgRef.current || !thumbnail) return;
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
}, [thumbnail]);
const scheduleRetry = useCallback(() => {
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
if (document.hidden) return;
retryTimerRef.current = setTimeout(() => {
retryCountRef.current++;
kickRetry();
}, RETRY_INTERVAL);
}, [thumbnail, kickRetry]);
// Subscribe to the shared visibility listener while a video card is retrying.
useEffect(() => {
if (!isVideo || !thumbnail || !retrying) return;
const unsubscribe = onTabVisible(() => {
retryCountRef.current = 0;
kickRetry();
});
return () => {
unsubscribe();
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, [isVideo, thumbnail, retrying, kickRetry]);
// Reset retry state when the thumbnail URL changes.
useEffect(() => {
if (!isVideo) return;
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}, [isVideo, thumbnail]);
const handleLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo && retrying) {
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}
onLoad?.(e);
},
[isVideo, retrying, onLoad],
);
const handleError = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo) {
setRetrying(true);
scheduleRetry();
} else {
const target = e.currentTarget;
if (target.dataset.fallback) return;
target.dataset.fallback = "1";
target.src = PLACEHOLDER_IMAGES.DEFAULT;
target.style.opacity = "0.3";
}
},
[isVideo, scheduleRetry],
);
if (retrying) {
return (
<>
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg text-white/30"
/>
{showRetryLabel && (
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
)}
</div>
{/* Hidden img retries in the background via ref — zero re-renders */}
<img
ref={retryImgRef}
src={thumbnail!}
alt=""
className="absolute h-0 w-0 opacity-0"
aria-hidden
onLoad={handleLoad}
onError={handleError}
/>
</>
);
}
if (thumbnail) {
return (
<img
src={thumbnail}
alt={alt}
loading="lazy"
decoding="async"
className={imgClassName}
onLoad={handleLoad}
onError={handleError}
/>
);
}
return (
<div className="flex h-full w-full items-center justify-center">
<FontAwesomeIcon icon={fallbackIcon} className={fallbackIconClassName} />
</div>
);
}
@@ -1,2 +1,53 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGrid2, faListUl } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
useGalleryViewStore,
type GalleryViewMode,
} from "../../lib/gallery-view-store";
const OPTIONS: {
mode: GalleryViewMode;
icon: IconDefinition;
label: string;
}[] = [
{ mode: "grid", icon: faGrid2, label: "Grid view" },
{ mode: "list", icon: faListUl, label: "List view" },
];
// Segmented grid/list switch for the create-page galleries. Lives in the
// TopBar; the selection is shared with the gallery via useGalleryViewStore.
export function GalleryViewToggle() {
const viewMode = useGalleryViewStore((s) => s.viewMode);
const setViewMode = useGalleryViewStore((s) => s.setViewMode);
return (
<div
role="group"
aria-label="Gallery layout"
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
>
{OPTIONS.map(({ mode, icon, label }) => {
const active = viewMode === mode;
return (
<Tooltip key={mode} content={label} position="bottom" delay={300}>
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={() => setViewMode(mode)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${
active
? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80"
}`}
>
<FontAwesomeIcon icon={icon} />
</button>
</Tooltip>
);
})}
</div>
);
}
@@ -1,26 +1,29 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Masonry from "react-masonry-css";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { PendingCard } from "./PendingCard";
import { FailedCard } from "./FailedCard";
import { GalleryCard } from "./GalleryCard";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
GenerationGridView,
type GalleryItem,
type RecreateSlotContext,
} from "@storyteller/ui-generation-list";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { useGalleryItemActions } from "./useGalleryItemActions";
useMergedGalleryEntries,
useInfiniteScrollSentinel,
} from "./useGalleryEntries";
import type { GenerationGalleryProps } from "./types";
// Thin wrapper over the shared masonry grid: injects the webapp-only
// affordances (recreate navigation, share/download actions) through the
// view's render-prop seams. Layout and card markup live in
// @storyteller/ui-generation-list and are shared with the desktop app.
// ── Grid layout constants ─────────────────────────────────────────────────
const BREAKPOINT_COLS = {
default: 4,
1280: 4,
900: 3,
640: 2,
};
// 12px gap on both axes (≈ Tailwind gap-3).
// ml-[-12px] on container offsets the first column's pl-[8px].
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
const COLUMN_CLASS = "pl-[8px]";
// ── Component ──────────────────────────────────────────────────────────────
export function GenerationGalleryGrid({
inProgressJobs,
@@ -30,163 +33,80 @@ export function GenerationGalleryGrid({
galleryItems,
newlyCompletedTokens,
hasMore,
isLoading,
isInitialLoading,
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
}: GenerationGalleryProps) {
return (
<GenerationGridView
inProgressJobs={inProgressJobs}
failedJobs={failedJobs}
onDismissFailed={onDismissFailed}
newlyCompletedItems={newlyCompletedItems}
galleryItems={galleryItems}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={hasMore}
isLoading={isLoading}
isInitialLoading={isInitialLoading}
onLoadMore={onLoadMore}
onGalleryItemClick={onGalleryItemClick}
renderRecreate={(ctx) => <CardRecreateButton {...ctx} />}
renderGalleryActions={(item) => (
<CardActions item={item} enableMakeVideo={enableMakeVideo} />
)}
/>
);
}
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
// Recreate affordance for pending + failed cards. The pending card shows a
// hover-revealed icon button by the prompt; the failed card a labeled button
// next to Dismiss.
function CardRecreateButton({
promptToken,
mediaClass,
kind,
}: RecreateSlotContext) {
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
mediaClass,
);
const mergedEntries = useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
});
if (kind === "failed") {
if (isInitialLoading) {
return (
<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>
<div className="flex justify-center py-20">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
);
}
return (
<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/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
);
}
// Hover quick actions for completed cards (recreate / make-video / share /
// download), styled for the card's dark action pill.
function CardActions({
item,
enableMakeVideo,
}: {
item: GalleryItem;
enableMakeVideo?: boolean;
}) {
const {
recreateMediaClass,
canMakeVideo,
isRecreating,
isDownloading,
shareCopied,
handleRecreate,
handleMakeVideo,
handleShare,
handleDownload,
} = useGalleryItemActions(item, { enableMakeVideo });
const buttonClass =
"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";
return (
<>
{recreateMediaClass && (
<Tooltip content="Recreate" position="top">
<button
type="button"
className={buttonClass}
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={buttonClass}
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={buttonClass}
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={buttonClass}
aria-label="Download"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
<Masonry
breakpointCols={BREAKPOINT_COLS}
className={MASONRY_CLASS}
columnClassName={COLUMN_CLASS}
>
{mergedEntries.map((entry) => (
<div key={entry.key} className="mb-[8px]">
{entry.kind === "pending" && (
<PendingCard
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
/>
)}
{entry.kind === "failed" && (
<FailedCard
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
failureReason={entry.job.failureReason}
failureMessage={entry.job.failureMessage}
refImageUrl={entry.job.refImageUrl}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
onDismiss={onDismissFailed}
/>
)}
{entry.kind === "gallery" && (
<GalleryCard
item={entry.item}
onClick={onGalleryItemClick}
enableMakeVideo={enableMakeVideo}
/>
)}
</div>
))}
</Masonry>
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
)}
</>
);
@@ -1,30 +1,19 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCallback, useMemo } from "react";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { PendingRow } from "./PendingRow";
import { FailedRow } from "./FailedRow";
import { GalleryRow } from "./GalleryRow";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
GenerationListView,
type GalleryItem,
type RecreateSlotContext,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import {
useGalleryItemActions,
type GalleryItemActions,
} from "./useGalleryItemActions";
useMergedGalleryEntries,
useInfiniteScrollSentinel,
} from "./useGalleryEntries";
import { usePrompts } from "../../lib/prompts-cache";
import { useMediaPromptTokens } from "../../lib/media-prompt-token-cache";
import type { GenerationGalleryProps } from "./types";
// Thin wrapper over the shared list view: injects the webapp-only affordances
// (recreate navigation, share/download actions, toasts) through the view's
// render-prop seams. Layout and row markup live in
// @storyteller/ui-generation-list and are shared with the desktop app.
// Constrains the feed to the promptbox width (max-w-5xl) and stacks one row
// per generation, mirroring the masonry grid's merged in-progress / failed /
// completed feed.
export function GenerationGalleryList({
inProgressJobs,
@@ -34,176 +23,131 @@ export function GenerationGalleryList({
galleryItems,
newlyCompletedTokens,
hasMore,
isLoading,
isInitialLoading,
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
}: GenerationGalleryProps) {
return (
<GenerationListView
inProgressJobs={inProgressJobs}
failedJobs={failedJobs}
onDismissFailed={onDismissFailed}
newlyCompletedItems={newlyCompletedItems}
galleryItems={galleryItems}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={hasMore}
isLoading={isLoading}
isInitialLoading={isInitialLoading}
onLoadMore={onLoadMore}
onGalleryItemClick={onGalleryItemClick}
renderRecreate={(ctx) => <RowRecreateButton {...ctx} />}
renderGalleryActions={(item, { modelId }) => (
<GalleryRowHoverActions
item={item}
modelId={modelId}
enableMakeVideo={enableMakeVideo}
/>
)}
onCopyPromptResult={handleCopyResult}
/>
);
}
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
function handleCopyResult(success: boolean) {
if (success) {
toast.success("Prompt copied");
} else {
toast.error("Unable to copy prompt");
const mergedEntries = useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
});
// Show the real prompt text + model on completed rows instead of the
// "Image Generation" placeholder. The profile media-list endpoint doesn't
// return prompt tokens yet, so for items missing one we resolve it through
// the batch media-files endpoint (media token → prompt token), then feed the
// prompt token into the prompts cache (prompt token → text + model). Both
// steps are batched + cached. Pending/failed entries already carry a prompt.
const mediaTokensNeedingPrompt = useMemo(
() =>
mergedEntries.flatMap((entry) =>
entry.kind === "gallery" && !entry.item.promptToken
? [entry.item.id]
: [],
),
[mergedEntries],
);
const resolvedPromptTokens = useMediaPromptTokens(mediaTokensNeedingPrompt);
// For an item: its (non-empty) prompt token, and whether the media→prompt
// lookup has finished. `resolved` lets rows show a skeleton while loading and
// fall back to the label once we know there's no prompt.
const promptStateFor = useCallback(
(item: { id: string; promptToken?: string }) => {
const direct = item.promptToken;
const resolved = direct != null || resolvedPromptTokens.has(item.id);
const promptToken = direct || resolvedPromptTokens.get(item.id) || "";
return { promptToken, resolved };
},
[resolvedPromptTokens],
);
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const entry of mergedEntries) {
if (entry.kind !== "gallery") continue;
const { promptToken } = promptStateFor(entry.item);
if (promptToken) tokens.push(promptToken);
}
return tokens;
}, [mergedEntries, promptStateFor]);
const promptsMap = usePrompts(promptTokens);
if (isInitialLoading) {
return (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
);
}
}
// Recreate button for pending + failed rows. Pending rows render it inside the
// lib's hover-revealed cluster; failed rows show it inline (dimmer tint).
function RowRecreateButton({
promptToken,
mediaClass,
kind,
}: RecreateSlotContext) {
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
mediaClass,
);
return (
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className={
kind === "pending"
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
: "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>
);
}
function GalleryRowHoverActions({
item,
modelId,
enableMakeVideo,
}: {
item: GalleryItem;
modelId?: string;
enableMakeVideo?: boolean;
}) {
const actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
return <GalleryRowActions actions={actions} hasDownload={!!item.fullImage} />;
}
// 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" : ""}`}
<div className="mx-auto w-full max-w-5xl sm:px-4">
<div className="flex flex-col divide-y divide-white/[0.04]">
{mergedEntries.map((entry) => {
if (entry.kind === "pending") {
return (
<PendingRow
key={entry.key}
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
/>
);
}
if (entry.kind === "failed") {
return (
<FailedRow
key={entry.key}
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
failureReason={entry.job.failureReason}
failureMessage={entry.job.failureMessage}
refImageUrl={entry.job.refImageUrl}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
onDismiss={onDismissFailed}
/>
);
}
const { promptToken, resolved } = promptStateFor(entry.item);
const prompt = promptToken ? promptsMap.get(promptToken) : undefined;
// Loading while the media→prompt lookup is pending, or it found a
// prompt token whose text hasn't arrived yet.
const loading = !resolved || (!!promptToken && !prompt);
return (
<GalleryRow
key={entry.key}
item={entry.item}
onClick={onGalleryItemClick}
enableMakeVideo={enableMakeVideo}
title={prompt?.maybe_positive_prompt?.trim() || undefined}
modelId={prompt?.maybe_model_type || undefined}
loading={loading}
/>
</button>
</Tooltip>
);
})}
</div>
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
)}
{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>
)}
</>
</div>
);
}
@@ -1,7 +1,12 @@
import { memo, type ReactNode } from "react";
import { memo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
import {
faArrowRotateRight,
faSpinnerThird,
} 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 { derivePendingStatus } from "./pending-status";
export interface PendingCardProps {
@@ -12,9 +17,9 @@ export interface PendingCardProps {
progress?: number;
estimatedTimeLeftMs?: number;
batchCount?: number;
mediaClass: "image" | "video";
/** Hover-revealed action (e.g. a Recreate button) next to the prompt. */
recreateSlot?: ReactNode;
// Prompt token + media class enable the "Recreate" action mid-generation.
promptToken?: string;
recreateMediaClass: "image" | "video";
}
export const PendingCard = memo(function PendingCard({
@@ -24,24 +29,26 @@ export const PendingCard = memo(function PendingCard({
progress,
estimatedTimeLeftMs,
batchCount,
mediaClass,
recreateSlot,
promptToken,
recreateMediaClass,
}: PendingCardProps) {
const { progressPercent, timeLabel } = derivePendingStatus(
progress,
estimatedTimeLeftMs,
);
const iconPath = getCreatorIconPathForModelId(modelId);
const iconPath = getModelCreatorIconPath(modelId);
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
recreateMediaClass,
);
return (
<div className="group relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
<div className="animate-shimmer h-full w-full" />
{batchCount != null && batchCount > 1 && (
<div className="absolute left-2 right-2 top-2 z-10 rounded-md bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm">
Generating {batchCount}{" "}
{mediaClass === "image" ? "images" : "videos"} · Results may appear
one at a time
Generating {batchCount} videos · Results may appear one at a time
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
@@ -63,7 +70,22 @@ export const PendingCard = memo(function PendingCard({
<p className="line-clamp-3 min-w-0 flex-1 text-xs leading-relaxed text-white/80">
{prompt}
</p>
{recreateSlot}
{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/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<img
@@ -1,23 +1,15 @@
import { memo, type ReactNode } from "react";
import { memo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
import {
faArrowRotateRight,
faSpinnerThird,
} 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";
import { derivePendingStatus } from "./pending-status";
export interface PendingRowProps {
id: string;
modelId: string;
modelLabel: string;
prompt: string;
progress?: number;
estimatedTimeLeftMs?: number;
batchCount?: number;
mediaClass: "image" | "video";
/** Hover-revealed action (e.g. a Recreate button) after the model label. */
recreateSlot?: ReactNode;
onCopyPromptResult?: (success: boolean) => void;
}
import type { PendingCardProps } from "./PendingCard";
export const PendingRow = memo(function PendingRow({
modelId,
@@ -26,15 +18,18 @@ export const PendingRow = memo(function PendingRow({
progress,
estimatedTimeLeftMs,
batchCount,
mediaClass,
recreateSlot,
onCopyPromptResult,
}: PendingRowProps) {
promptToken,
recreateMediaClass,
}: PendingCardProps) {
const { progressPercent, timeLabel } = derivePendingStatus(
progress,
estimatedTimeLeftMs,
);
const iconPath = getCreatorIconPathForModelId(modelId);
const iconPath = getModelCreatorIconPath(modelId);
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
recreateMediaClass,
);
return (
<div className="group flex items-center gap-3 rounded-lg px-2.5 py-2">
@@ -63,9 +58,7 @@ export const PendingRow = memo(function PendingRow({
Generating…
</p>
)}
{prompt.trim() && (
<CopyPromptButton text={prompt} onCopyResult={onCopyPromptResult} />
)}
{prompt.trim() && <CopyPromptButton text={prompt} />}
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-white/45">
<img
@@ -77,16 +70,27 @@ export const PendingRow = memo(function PendingRow({
{batchCount != null && batchCount > 1 && (
<>
<span className="text-white/25">·</span>
<span className="shrink-0">
{batchCount} {mediaClass === "image" ? "images" : "videos"}
</span>
<span className="shrink-0">{batchCount} videos</span>
</>
)}
{/* Recreate is hover-revealed on desktop (always visible on mobile),
mirroring the completed GalleryRow's quick actions. */}
{recreateSlot && (
{promptToken && (
<div className="flex shrink-0 items-center sm:ms-1 sm:opacity-0 transition-opacity sm:group-hover:opacity-100 sm:focus-within:opacity-100">
{recreateSlot}
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 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>
</div>
)}
</div>
@@ -2,6 +2,12 @@ export { GenerationGallery } from "./GenerationGallery";
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
export { GenerationGalleryList } from "./GenerationGalleryList";
export { GalleryViewToggle } from "./GalleryViewToggle";
export { GalleryCard } from "./GalleryCard";
export { GalleryRow } from "./GalleryRow";
export { PendingCard, type PendingCardProps } from "./PendingCard";
export { PendingRow } from "./PendingRow";
export { FailedCard, type FailedCardProps } from "./FailedCard";
export { FailedRow } from "./FailedRow";
export { useGalleryData, type GalleryItem } from "./useGalleryData";
export {
useGenerationJobs,
@@ -1,5 +1,32 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export {
derivePendingStatus,
type PendingStatus,
} from "@storyteller/ui-generation-list";
// Progress + time-left derivation shared by the pending card and pending row.
export interface PendingStatus {
progressPercent: number | null;
timeLabel: string | null;
}
export function derivePendingStatus(
progress?: number,
estimatedTimeLeftMs?: number,
): PendingStatus {
const progressPercent =
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
const isAlmostDone = progress != null && progress >= 95;
const timeLabel = isAlmostDone
? "Almost done..."
: estimatedTimeLeftMs != null && estimatedTimeLeftMs > 0
? formatTimeLeft(estimatedTimeLeftMs)
: null;
return { progressPercent, timeLabel };
}
function formatTimeLeft(ms: number): string {
const totalSeconds = Math.ceil(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0 && minutes > 0) return `~${hours}h ${minutes}m`;
if (hours > 0) return `~${hours}h`;
if (minutes > 0) return `~${minutes}m`;
return `~${seconds}s`;
}
@@ -1,2 +1,170 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { useGalleryData, type GalleryItem } from "@storyteller/ui-generation-list";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
GalleryModalApi,
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
// ── Types ──────────────────────────────────────────────────────────────────
export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
fullImage: string | null;
createdAt: string;
mediaClass: string;
modelId?: string;
batchImageToken?: string;
// Token for the generation's prompt record. The list view resolves it
// (via the shared prompts cache) to show the real prompt + model.
promptToken?: string;
}
const PAGE_SIZE = 40;
const getLabel = (item: any) => {
if (item.maybe_title) return item.maybe_title;
switch (item.media_class) {
case "image":
return "Image Generation";
case "video":
return "Video Generation";
case "dimensional":
return "3D Mesh";
default:
return "Generation";
}
};
// ── Hook ───────────────────────────────────────────────────────────────────
export function useGalleryData(options: {
username: string | null;
filterMediaClasses: FilterMediaClasses[];
excludeUploads?: boolean;
}) {
const { username, filterMediaClasses, excludeUploads } = options;
const [items, setItems] = useState<GalleryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(true);
const isLoadingRef = useRef(false);
const api = useMemo(() => new GalleryModalApi(), []);
const mapApiItem = useCallback((item: any): GalleryItem => {
const isDimensional = item.media_class === "dimensional";
const thumbnail = isDimensional
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
fullImage: item.media_links?.cdn_url || null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
modelId: item.maybe_model_type || undefined,
batchImageToken: item.maybe_batch_token,
promptToken: item.maybe_prompt_token || undefined,
};
}, []);
const loadItems = useCallback(
async (reset = false) => {
if (!username) return;
if (isLoadingRef.current) return;
isLoadingRef.current = true;
setIsLoading(true);
try {
const response = await api.listUserMediaFiles({
username,
filter_media_classes: filterMediaClasses,
include_user_uploads: !excludeUploads,
page_index: reset ? 0 : pageIndex,
page_size: PAGE_SIZE,
});
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
!(excludeUploads && item.origin_category === "upload"),
)
.map(mapApiItem);
if (reset) {
setItems(newItems);
} else {
setItems((prev) => [...prev, ...newItems]);
}
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
}
} catch {
// ignore
}
setIsLoading(false);
setIsInitialLoading(false);
isLoadingRef.current = false;
},
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
);
// Initial load + filter change. When logged out (no username), clear the
// loading flag so the shell renders the empty state instead of a spinner.
useEffect(() => {
setItems([]);
setPageIndex(0);
isLoadingRef.current = false;
if (!username) {
setHasMore(false);
setIsInitialLoading(false);
return;
}
setHasMore(true);
setIsInitialLoading(true);
loadItems(true);
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
const loadMore = useCallback(() => {
if (hasMore && !isLoadingRef.current) {
loadItems();
}
}, [hasMore, loadItems]);
const refresh = useCallback(() => {
setItems([]);
setPageIndex(0);
setHasMore(true);
isLoadingRef.current = false;
loadItems(true);
}, [loadItems]);
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
return {
items,
isLoading,
isInitialLoading,
hasMore,
loadMore,
refresh,
removeItem,
};
}
@@ -1,7 +1,98 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export {
useMergedGalleryEntries,
useInfiniteScrollSentinel,
type GalleryEntry,
type MergedEntriesInput,
} from "@storyteller/ui-generation-list";
import { useEffect, useMemo, useRef } from "react";
import type { FailedJob, InProgressJob } from "./useGenerationJobs";
import type { GalleryItem } from "./useGalleryData";
// Shared between the grid and list views: both merge the in-progress / failed /
// completed streams into a single time-sorted feed and lazy-load more on scroll.
export type GalleryEntry =
| { kind: "pending"; key: string; createdAt: number; job: InProgressJob }
| { kind: "failed"; key: string; createdAt: number; job: FailedJob }
| { kind: "gallery"; key: string; createdAt: number; item: GalleryItem };
export interface MergedEntriesInput {
inProgressJobs: InProgressJob[];
failedJobs: FailedJob[];
newlyCompletedItems: GalleryItem[];
galleryItems: GalleryItem[];
newlyCompletedTokens: Set<string>;
}
export function useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
}: MergedEntriesInput): GalleryEntry[] {
return useMemo(() => {
const entries: GalleryEntry[] = [];
for (const job of inProgressJobs) {
entries.push({
kind: "pending",
key: job.id,
createdAt: new Date(job.createdAt).getTime(),
job,
});
}
for (const job of failedJobs) {
entries.push({
kind: "failed",
key: job.id,
createdAt: new Date(job.createdAt).getTime(),
job,
});
}
for (const item of newlyCompletedItems) {
entries.push({
kind: "gallery",
key: `new-${item.id}`,
createdAt: new Date(item.createdAt).getTime(),
item,
});
}
for (const item of galleryItems) {
if (newlyCompletedTokens.has(item.id)) continue;
entries.push({
kind: "gallery",
key: item.id,
createdAt: new Date(item.createdAt).getTime(),
item,
});
}
entries.sort((a, b) => b.createdAt - a.createdAt);
return entries;
}, [
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
]);
}
// Returns a ref to attach to a sentinel element near the end of the feed;
// `onLoadMore` fires when it scrolls into view (with a 400px lookahead).
export function useInfiniteScrollSentinel(
hasMore: boolean,
onLoadMore: () => void,
) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onLoadMore();
},
{ rootMargin: "400px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, onLoadMore]);
return sentinelRef;
}
@@ -11,11 +11,35 @@ import type { GalleryItem } from "./useGalleryData";
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
// ── Types ──────────────────────────────────────────────────────────────────
// The job shapes moved to the shared generation-list lib; re-exported here so
// existing import sites keep working.
export type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
import type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
export interface InProgressJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
progress: number;
estimatedTimeLeftMs?: number;
createdAt: string;
batchCount?: number;
// Prompt token + media class enable the "Recreate" action while the job is
// still running, mirroring the failed/completed cards.
promptToken?: string;
mediaClass: "image" | "video";
}
export interface FailedJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
failureReason?: string;
failureMessage?: string;
status: string;
createdAt: string;
promptToken?: string;
refImageUrl?: string;
mediaClass: "image" | "video";
}
// ── Constants ──────────────────────────────────────────────────────────────
@@ -1,2 +1,27 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { formatTimeAgo } from "@storyteller/ui-generation-list";
// Compact relative-time formatter ("just now", "5 minutes ago", "2 months
// ago") for gallery list rows. Kept dependency-free — the app has no date
// library, and this avoids pulling one in for a single label.
const MINUTE = 60;
const HOUR = MINUTE * 60;
const DAY = HOUR * 24;
const WEEK = DAY * 7;
const MONTH = DAY * 30;
const YEAR = DAY * 365;
const pluralize = (value: number, unit: string): string =>
`${value} ${unit}${value === 1 ? "" : "s"} ago`;
export function formatTimeAgo(input: string | number | Date): string {
const date = input instanceof Date ? input : new Date(input);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (!Number.isFinite(seconds) || seconds < 0) return "just now";
if (seconds < 45) return "just now";
if (seconds < HOUR) return pluralize(Math.round(seconds / MINUTE), "minute");
if (seconds < DAY) return pluralize(Math.round(seconds / HOUR), "hour");
if (seconds < WEEK) return pluralize(Math.round(seconds / DAY), "day");
if (seconds < MONTH) return pluralize(Math.round(seconds / WEEK), "week");
if (seconds < YEAR) return pluralize(Math.round(seconds / MONTH), "month");
return pluralize(Math.round(seconds / YEAR), "year");
}
@@ -1,6 +1,23 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
// Re-exported so every webapp consumer shares the lib's store instance.
export {
useGalleryViewStore,
type GalleryViewMode,
} from "@storyteller/ui-generation-list";
import { create } from "zustand";
import { persist } from "zustand/middleware";
// View mode for the create-page generation galleries. Shared between the
// create-image / create-video pages and the TopBar toggle, and persisted so
// the user's preference survives reloads and carries across both pages.
export type GalleryViewMode = "grid" | "list";
interface GalleryViewState {
viewMode: GalleryViewMode;
setViewMode: (mode: GalleryViewMode) => void;
}
export const useGalleryViewStore = create<GalleryViewState>()(
persist(
(set) => ({
viewMode: "grid",
setViewMode: (viewMode) => set({ viewMode }),
}),
{ name: "artcraft-gallery-view" },
),
);
@@ -1,3 +1,104 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
// Re-exported so every webapp consumer shares the lib's module-level cache.
export { useMediaPromptTokens } from "@storyteller/ui-generation-list";
import { useEffect, useState } from "react";
import { MediaFilesApi } from "@storyteller/api";
// Workaround until the profile media-list endpoint returns prompt tokens:
// resolve media token → prompt token via the batch media-files endpoint
// (GET /v1/media_files/batch), which does include `maybe_prompt_token`. The
// resolved prompt token is then fed into the prompts cache (usePrompts) to get
// the prompt text + model. Mirrors prompts-cache.ts: module-level cache,
// in-flight dedupe, and a subscribe-on-load hook.
// mediaToken → promptToken ("" = resolved, but the media has no prompt token).
const tokenCache = new Map<string, string>();
const inFlight = new Set<string>();
const subscribers = new Set<() => void>();
let api: MediaFilesApi | null = null;
// Keep request URLs (one `tokens=` param per token) comfortably under length limits.
const CHUNK_SIZE = 50;
function getApi(): MediaFilesApi {
if (!api) api = new MediaFilesApi();
return api;
}
function notifySubscribers() {
for (const cb of subscribers) cb();
}
async function fetchMissing(mediaTokens: readonly string[]): Promise<void> {
const missing = mediaTokens.filter(
(t) => t && !tokenCache.has(t) && !inFlight.has(t),
);
if (missing.length === 0) return;
for (const t of missing) inFlight.add(t);
try {
const chunks: string[][] = [];
for (let i = 0; i < missing.length; i += CHUNK_SIZE) {
chunks.push(missing.slice(i, i + CHUNK_SIZE));
}
const responses = await Promise.all(
chunks.map((chunk) =>
getApi().ListMediaFilesByTokens({ mediaTokens: chunk }),
),
);
for (const response of responses) {
if (!response.success || !response.data) continue;
for (const file of response.data) {
tokenCache.set(file.token, file.maybe_prompt_token ?? "");
}
}
} catch {
// Swallow — the finally block marks the tokens resolved (empty) so the
// loading skeleton clears and we don't refetch in a loop.
} finally {
// Mark every requested token resolved. Tokens that errored or weren't
// returned get "" (no prompt) so the row falls back to its label instead
// of showing a skeleton forever. Always notify so skeletons clear.
for (const t of missing) {
if (!tokenCache.has(t)) tokenCache.set(t, "");
inFlight.delete(t);
}
notifySubscribers();
}
}
// Returns mediaToken → promptToken for every token whose media→prompt lookup
// has completed; the value is "" when the media has no prompt. A token that is
// absent from the map is still resolving (use `.has()` to distinguish that
// from a resolved-but-promptless item). Triggers a batched fetch for any not
// yet resolved.
export function useMediaPromptTokens(
mediaTokens: readonly (string | null | undefined)[],
): Map<string, string> {
const [, forceUpdate] = useState(0);
useEffect(() => {
const cb = () => forceUpdate((n) => n + 1);
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}, []);
const cleaned: string[] = [];
for (const t of mediaTokens) {
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list, order-independent.
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, string>();
for (const t of cleaned) {
const promptToken = tokenCache.get(t);
// Include "" entries so callers can tell "resolved, no prompt" (present)
// apart from "still loading" (absent).
if (promptToken !== undefined) result.set(t, promptToken);
}
return result;
}
@@ -1,3 +1,73 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
// Re-exported so every webapp consumer shares the lib's module-level cache.
export { getCachedPrompt, usePrompts } from "@storyteller/ui-generation-list";
import { useEffect, useState } from "react";
import { PromptsApi } from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
const promptCache = new Map<string, Prompts>();
const inFlight = new Set<string>();
const subscribers = new Set<() => void>();
let api: PromptsApi | null = null;
function getApi(): PromptsApi {
if (!api) api = new PromptsApi();
return api;
}
function notifySubscribers() {
for (const cb of subscribers) cb();
}
async function fetchMissing(tokens: readonly string[]): Promise<void> {
const missing = tokens.filter(
(t) => t && !promptCache.has(t) && !inFlight.has(t),
);
if (missing.length === 0) return;
for (const t of missing) inFlight.add(t);
try {
const response = await getApi().BatchGetPrompts({ tokens: missing });
if (response.success && response.data) {
for (const p of response.data) {
promptCache.set(p.token, p);
}
notifySubscribers();
}
} catch {
// Failures will be retried when usePrompts re-runs with the same tokens.
} finally {
for (const t of missing) inFlight.delete(t);
}
}
export function getCachedPrompt(token: string): Prompts | undefined {
return promptCache.get(token);
}
export function usePrompts(tokens: readonly (string | null | undefined)[]): Map<string, Prompts> {
const [, forceUpdate] = useState(0);
useEffect(() => {
const cb = () => forceUpdate((n) => n + 1);
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}, []);
const cleaned: string[] = [];
for (const t of tokens) {
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list. Sorting keeps it stable when
// a poll returns the same set in a different order.
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, Prompts>();
for (const t of cleaned) {
const p = promptCache.get(t);
if (p) result.set(t, p);
}
return result;
}

Some files were not shown because too many files have changed in this diff Show More