Compare commits

..
Author SHA1 Message Date
bflatastic dc892076d0 app status and update notifications 2026-04-06 22:32:23 +07:00
28 changed files with 537 additions and 373 deletions
Generated
+1
View File
@@ -6089,6 +6089,7 @@ dependencies = [
name = "jwt_signer"
version = "0.0.1"
dependencies = [
"errors",
"hmac 0.10.1",
"jwt",
"sha2 0.9.9",
@@ -6,11 +6,12 @@
use actix_web::cookie::time::OffsetDateTime;
use actix_web::cookie::{Cookie, SameSite};
use actix_web::HttpRequest;
use anyhow::anyhow;
use log::warn;
use crate::sessions::http_user_session_payload::HttpUserSessionPayload;
use crate::sessions::http_user_session_payload_signer::HttpUserSessionPayloadSigner;
use crate::sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
use crate::sessions::payload_signer::HttpUserSessionPayloadSigner;
use errors::AnyhowResult;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
@@ -30,41 +31,19 @@ pub struct HttpUserSessionManager {
}
impl HttpUserSessionManager {
pub fn new(cookie_domain: &str, hmac_secret: &str) -> Result<Self, HttpUserSessionPayloadError> {
let payload_signer = HttpUserSessionPayloadSigner::new(hmac_secret)
.map_err(|e| {
warn!("Failed to construct HttpUserSessionPayloadSigner: {}", e);
e
})?;
pub fn new(cookie_domain: &str, hmac_secret: &str) -> AnyhowResult<Self> {
Ok(Self {
cookie_domain: cookie_domain.to_string(),
payload_signer,
payload_signer: HttpUserSessionPayloadSigner::new(hmac_secret)?,
})
}
pub fn encode_session_payload(
&self,
session_token: &UserSessionToken,
user_token: &UserToken,
) -> Result<String, HttpUserSessionPayloadError> {
pub fn encode_session_payload(&self, session_token: &UserSessionToken, user_token: &UserToken) -> AnyhowResult<String> {
self.payload_signer.encode(session_token, user_token)
.map_err(|e| {
warn!("Failed to encode session payload: {}", e);
e
})
}
pub fn create_cookie(
&self,
session_token: &UserSessionToken,
user_token: &UserToken,
) -> Result<Cookie, HttpUserSessionPayloadError> {
let jwt_string = self.payload_signer.encode(session_token, user_token)
.map_err(|e| {
warn!("Failed to encode session cookie payload: {}", e);
e
})?;
pub fn create_cookie(&self, session_token: &UserSessionToken, user_token: &UserToken) -> AnyhowResult<Cookie> {
let jwt_string = self.payload_signer.encode(session_token, user_token)?;
let make_secure = !self.cookie_domain.to_lowercase().contains("jungle.horse")
&& !self.cookie_domain.to_lowercase().contains("localhost");
@@ -97,8 +76,8 @@ impl HttpUserSessionManager {
pub fn decode_session_payload_from_request(
&self,
request: &HttpRequest,
) -> Result<Option<HttpUserSessionPayload>, HttpUserSessionPayloadError>
request: &HttpRequest
) -> AnyhowResult<Option<HttpUserSessionPayload>>
{
let signed_session_payload = self.session_payload_from_request(request)?;
@@ -109,8 +88,8 @@ impl HttpUserSessionManager {
match self.payload_signer.decode(&signed_session_payload) {
Err(e) => {
warn!("Session cookie decode error: {}", e);
Err(e)
warn!("Session cookie decode error: {:?}", e);
Err(anyhow!("Could not decode session cookie: {:?}", e))
},
Ok(payload) => Ok(Some(payload)),
}
@@ -118,10 +97,7 @@ impl HttpUserSessionManager {
// NB: THIS IS ONLY FOR A QUICK HACK FOR FREAKING CORS UGH
// THIS IS A HUGE STUPID SECURITY VULN. DAMNIT GOOGLE DAMNIT CORS.
pub fn check_and_return_session_token_decodes(
&self,
request: &HttpRequest,
) -> Result<Option<String>, HttpUserSessionPayloadError> {
pub fn check_and_return_session_token_decodes(&self, request: &HttpRequest) -> AnyhowResult<Option<String>> {
let signed_session_payload = self.session_payload_from_request(request)?;
let signed_session_payload = match signed_session_payload {
@@ -131,8 +107,8 @@ impl HttpUserSessionManager {
match self.payload_signer.decode(&signed_session_payload) {
Err(e) => {
warn!("Session cookie decode error: {}", e);
return Err(e);
warn!("Session cookie decode error: {:?}", e);
return Err(anyhow!("Could not decode session cookie: {:?}", e));
},
Ok(_payload) => {}, // Good! We'll just discard this.
}
@@ -140,23 +116,15 @@ impl HttpUserSessionManager {
Ok(Some(signed_session_payload))
}
fn session_payload_from_request(
&self,
request: &HttpRequest,
) -> Result<Option<String>, HttpUserSessionPayloadError> {
let mut signed_session_payload = request.cookie(SESSION_COOKIE_NAME)
fn session_payload_from_request(&self, request: &HttpRequest) -> AnyhowResult<Option<String>> {
let mut signed_session_payload= request.cookie(SESSION_COOKIE_NAME)
.map(|cookie| cookie.value().to_string());
if signed_session_payload.is_none() {
let maybe_header = request.headers().get(SESSION_HEADER_NAME);
if let Some(header) = maybe_header {
let header_str = header.to_str()
.map_err(|e| {
warn!("Failed to read session HTTP header as str: {}", e);
HttpUserSessionPayloadError::HttpSessionHeaderError(e.to_string())
})?;
signed_session_payload = Some(header_str.to_string());
}
signed_session_payload = request.headers().get(SESSION_HEADER_NAME)
.map(|header| header.to_str())
.transpose()?
.map(|payload| payload.to_string());
}
Ok(signed_session_payload)
@@ -1,30 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use jwt_signer::jwt_signer_error::JwtSignerError;
#[derive(Debug)]
pub enum HttpUserSessionPayloadError {
/// Error reading HTTP header
HttpSessionHeaderError(String),
/// Error encoding, decoding, or constructing the JWT signer.
JwtSigner(JwtSignerError),
}
impl Display for HttpUserSessionPayloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::HttpSessionHeaderError(msg) => write!(f, "HTTP session header error: {}", msg),
Self::JwtSigner(e) => write!(f, "JWT signer error: {}", e),
}
}
}
impl Error for HttpUserSessionPayloadError {}
impl From<JwtSignerError> for HttpUserSessionPayloadError {
fn from(err: JwtSignerError) -> Self {
Self::JwtSigner(err)
}
}
@@ -1,4 +1,3 @@
pub mod http_user_session_manager;
pub mod http_user_session_payload;
pub mod http_user_session_payload_error;
pub mod http_user_session_payload_signer;
pub mod payload_signer;
@@ -1,8 +1,8 @@
use std::collections::BTreeMap;
use crate::sessions::http_user_session_payload::HttpUserSessionPayload;
use crate::sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
use jwt_signer::jwt_signer::JwtSigner;
use errors::AnyhowResult;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
@@ -21,18 +21,14 @@ pub struct HttpUserSessionPayloadSigner {
}
impl HttpUserSessionPayloadSigner {
pub fn new(hmac_secret: &str) -> Result<Self, HttpUserSessionPayloadError> {
pub fn new(hmac_secret: &str) -> AnyhowResult<Self> {
Ok(Self {
jwt_signer: JwtSigner::new(hmac_secret)?,
jwt_signer: JwtSigner::new(hmac_secret)?
})
}
pub fn encode(
&self,
session_token: &UserSessionToken,
user_token: &UserToken,
) -> Result<String, HttpUserSessionPayloadError> {
let mut claims: BTreeMap<&str, &str> = BTreeMap::new();
pub fn encode(&self, session_token: &UserSessionToken, user_token: &UserToken) -> AnyhowResult<String> {
let mut claims = BTreeMap::new();
let payload_version = PAYLOAD_VERSION.to_string();
claims.insert("session_token", session_token.as_str());
@@ -40,14 +36,12 @@ impl HttpUserSessionPayloadSigner {
claims.insert("version", &payload_version);
let jwt_string = self.jwt_signer.claims_to_jwt(&claims)?;
Ok(jwt_string)
}
pub fn decode(
&self,
session_payload_contents: &str,
) -> Result<HttpUserSessionPayload, HttpUserSessionPayloadError> {
let claims = self.jwt_signer.jwt_to_claims(session_payload_contents)?;
pub fn decode(&self, session_payload_contents: &str) -> AnyhowResult<HttpUserSessionPayload> {
let claims = self.jwt_signer.jwt_to_claims(&session_payload_contents)?;
let session_token = claims["session_token"].clone();
let maybe_user_token = claims.get("user_token")
@@ -62,7 +56,7 @@ impl HttpUserSessionPayloadSigner {
#[cfg(test)]
mod tests {
use crate::sessions::http_user_session_payload_signer::HttpUserSessionPayloadSigner;
use crate::sessions::payload_signer::HttpUserSessionPayloadSigner;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
+3
View File
@@ -19,6 +19,9 @@ normal = ["workspace-build-acceleration"]
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
# Internal
errors.workspace = true
# External
hmac = "0.10.1" # jwt signing
jwt = "0.13.0"
+17 -16
View File
@@ -6,7 +6,7 @@ use jwt::SignWithKey;
use jwt::VerifyWithKey;
use sha2::Sha256;
use crate::jwt_signer_error::JwtSignerError;
use errors::{anyhow, AnyhowResult};
#[derive(Clone)]
pub struct JwtSigner {
@@ -15,9 +15,9 @@ pub struct JwtSigner {
impl JwtSigner {
pub fn new(hmac_secret: &str) -> Result<Self, JwtSignerError> {
pub fn new(hmac_secret: &str) -> AnyhowResult<Self> {
let hmac_key = Hmac::new_varkey(hmac_secret.as_bytes())
.map_err(|_| JwtSignerError::JwtInvalidKeyLength)?;
.map_err(|e| anyhow!("invalid hmac: {:?}", e))?;
Ok(Self {
hmac_key,
@@ -25,32 +25,33 @@ impl JwtSigner {
}
/// Turn a map of claims into a signed JWT cookie payload (string)
pub fn claims_to_jwt(&self, claims: &impl Claimable) -> Result<String, JwtSignerError> {
claims.sign(&self.hmac_key)
pub fn claims_to_jwt(&self, claims: &impl Claimable) -> AnyhowResult<String> {
let jwt_string = claims.sign(&self.hmac_key)?;
Ok(jwt_string)
}
/// Turn a JWT cookie payload (string) into a map of (key, value) claims
pub fn jwt_to_claims(&self, jwt: &str) -> Result<BTreeMap<String, String>, JwtSignerError> {
jwt.verify_with_key(&self.hmac_key)
.map_err(JwtSignerError::JwtVerifyError)
pub fn jwt_to_claims(&self, jwt: &str) -> AnyhowResult<BTreeMap<String, String>> {
let claims = jwt.verify_with_key(&self.hmac_key)?;
Ok(claims)
}
}
pub trait Claimable {
fn sign(&self, hmac_key: &Hmac<Sha256>) -> Result<String, JwtSignerError>;
fn sign(&self, hmac_key: &Hmac<Sha256>) -> AnyhowResult<String>;
}
impl Claimable for BTreeMap<String, String> {
fn sign(&self, hmac_key: &Hmac<Sha256>) -> Result<String, JwtSignerError> {
self.sign_with_key(hmac_key)
.map_err(JwtSignerError::JwtSignError)
fn sign(&self, hmac_key: &Hmac<Sha256>) -> AnyhowResult<String> {
let claims = self.sign_with_key(hmac_key)?;
Ok(claims)
}
}
impl Claimable for BTreeMap<&str, &str> {
fn sign(&self, hmac_key: &Hmac<Sha256>) -> Result<String, JwtSignerError> {
self.sign_with_key(hmac_key)
.map_err(JwtSignerError::JwtSignError)
fn sign(&self, hmac_key: &Hmac<Sha256>) -> AnyhowResult<String> {
let claims = self.sign_with_key(hmac_key)?;
Ok(claims)
}
}
@@ -91,4 +92,4 @@ mod tests {
assert_eq!("eyJhbGciOiJIUzI1NiJ9.eyJiYXIiOiJubyIsImZvbyI6InllcyJ9.k4eRvIrtw4tuGDtQqSmEDngtGYAaQVRYVMCK5N3JcZA", &jwt);
}
}
}
@@ -1,26 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum JwtSignerError {
/// Error decoding and verifying a JWT to claims
JwtVerifyError(jwt::Error),
/// Error encoding and signing JWT claims
JwtSignError(jwt::Error),
/// Error constructing the JWT signer.
JwtInvalidKeyLength,
}
impl Display for JwtSignerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::JwtVerifyError(e) => write!(f, "JWT verify error: {}", e),
Self::JwtSignError(e) => write!(f, "JWT sign error: {}", e),
Self::JwtInvalidKeyLength => write!(f, "JWT signer construction failed: invalid key length"),
}
}
}
impl Error for JwtSignerError {}
+1 -2
View File
@@ -18,5 +18,4 @@
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod jwt_signer;
pub mod jwt_signer_error;
pub mod jwt_signer;
@@ -2,11 +2,13 @@
#![forbid(unused_imports)]
#![forbid(unused_mut)]
use anyhow::anyhow;
use log::warn;
use sqlx::{Executor, MySql};
use sqlx::pool::PoolConnection;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false, nullable_i8_to_optional_bool};
@@ -97,7 +99,7 @@ impl SessionUserRecord {
pub async fn get_user_session_by_token_pooled_connection(
mysql_connection: &mut PoolConnection<MySql>,
session_token: &str,
) -> Result<Option<SessionUserRecord>, sqlx::Error> {
) -> AnyhowResult<Option<SessionUserRecord>> {
get_user_session_by_token(&mut **mysql_connection, session_token).await
}
@@ -105,7 +107,7 @@ pub async fn get_user_session_by_token_pooled_connection(
pub async fn get_user_session_by_token<'e, 'c : 'e, E>(
mysql_executor: E,
session_token: &str,
) -> Result<Option<SessionUserRecord>, sqlx::Error>
) -> AnyhowResult<Option<SessionUserRecord>>
where E: 'e + Executor<'c, Database = MySql>
{
// NB: Lookup failure is Err(RowNotFound).
@@ -234,13 +236,17 @@ WHERE user_sessions.token = ?
Ok(Some(result_user_record))
},
Err(sqlx::Error::RowNotFound) => {
warn!("Valid cookie; invalid session: {}", session_token);
Ok(None)
},
Err(err) => {
warn!("Session query error: {:?}", err);
Err(err)
match err {
sqlx::Error::RowNotFound => {
warn!("Valid cookie; invalid session: {}", session_token);
Ok(None)
},
_ => {
warn!("Session query error: {:?}", err);
Err(anyhow!("session query error: {:?}", err))
}
}
}
}
@@ -2,9 +2,12 @@
#![forbid(unused_imports)]
#![forbid(unused_mut)]
use anyhow::anyhow;
use log::warn;
use sqlx::{Executor, MySql};
use errors::AnyhowResult;
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub session_token: String,
@@ -14,7 +17,7 @@ pub struct SessionRecord {
pub async fn get_user_session_by_token_light<'e, 'c : 'e, E>(
mysql_executor: E,
session_token: &str,
) -> Result<Option<SessionRecord>, sqlx::Error>
) -> AnyhowResult<Option<SessionRecord>>
where E: 'e + Executor<'c, Database = MySql>
{
let maybe_session_record = sqlx::query_as!(
@@ -34,13 +37,17 @@ AND deleted_at IS NULL
match maybe_session_record {
Ok(session_record) => Ok(Some(session_record)),
Err(sqlx::Error::RowNotFound) => {
warn!("Valid cookie; invalid session: {}", session_token);
Ok(None)
},
Err(err) => {
warn!("Session query error: {:?}", err);
Err(err)
match err {
sqlx::Error::RowNotFound => {
warn!("Valid cookie; invalid session: {}", session_token);
Ok(None)
},
_ => {
warn!("Session query error: {:?}", err);
Err(anyhow!("session query error: {:?}", err))
}
}
}
}
}
@@ -905,6 +905,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListPinnedMediaFilesSuccessResponse,
ListPinnedWeightsError,
ListPinnedWeightsSuccessResponse,
ListSessionJobsError,
ListSessionJobsItem,
ListSessionJobsQueryParams,
ListSessionJobsSuccessResponse,
@@ -969,6 +970,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
SearchModelWeightsSortDirection,
SearchModelWeightsSortField,
SearchModelWeightsSuccessResponse,
SessionInfoError,
SessionTokenInfoError,
SessionTokenInfoSuccessResponse,
SetMediaFileCoverImageError,
@@ -1,13 +1,10 @@
use std::fmt::{Display, Formatter};
use std::sync::Arc;
use crate::http_server::common_responses::common_web_error::CommonWebError;
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::http_user_session_payload_error::HttpUserSessionPayloadError;
use actix_http::StatusCode;
use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError};
use anyhow::anyhow;
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
/// An error type for actix-web handlers that wraps causal errors for debugging
/// and paging while presenting safe, generic HTTP responses to users.
@@ -197,30 +194,9 @@ impl From<RequireUserSessionError> for AdvancedCommonWebError {
}
}
impl From<HttpUserSessionPayloadError> for AdvancedCommonWebError {
fn from(_value: HttpUserSessionPayloadError) -> Self {
// Any session error (bad header, bad JWT, construction failure) becomes 401.
Self::NotAuthorized
}
}
impl From<SessionCheckerError> for AdvancedCommonWebError {
fn from(value: SessionCheckerError) -> Self {
match value {
// Bad / forged session cookie → 401 (don't page on this)
// NOTE: If there's an elevated rate of across-the-board 401s,
// then we probably misconfigured the HMAC secret
SessionCheckerError::SessionPayload(_) => Self::NotAuthorized,
// Underlying DB / cache errors → 500 with paging
SessionCheckerError::Sqlx(err) => Self::from_error(err),
// Likely Redis caching middleware
SessionCheckerError::OtherError(err) => Self::from_anyhow_error(err),
}
}
}
impl From<CommonWebError> for AdvancedCommonWebError {
fn from(value: CommonWebError) -> Self {
impl From<crate::http_server::common_responses::common_web_error::CommonWebError> for AdvancedCommonWebError {
fn from(value: crate::http_server::common_responses::common_web_error::CommonWebError) -> Self {
use crate::http_server::common_responses::common_web_error::CommonWebError;
match value {
CommonWebError::BadInputWithSimpleMessage(msg) => Self::BadInputWithSimpleMessage(msg),
CommonWebError::NotAuthorized => Self::NotAuthorized,
@@ -4,7 +4,6 @@ use log::{error, warn};
use mysql_queries::errors::mysql_error::{MysqlCrateErrorSubtype, MysqlError};
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::http_server::session::session_checker_error::SessionCheckerError;
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
#[derive(Debug, utoipa::ToSchema)]
@@ -110,27 +109,6 @@ impl From<RequireUserSessionError> for CommonWebError {
}
}
impl From<SessionCheckerError> for CommonWebError {
fn from(value: SessionCheckerError) -> Self {
match value {
// Bad / forged session cookie → 401
// NOTE: If there's an elevated rate of across-the-board 401s,
// then we probably misconfigured the HMAC secret
SessionCheckerError::SessionPayload(_) => Self::NotAuthorized,
// Underlying DB / cache errors → 500
SessionCheckerError::Sqlx(err) => {
error!("SessionChecker sqlx error: {:?}", err);
Self::ServerError
},
// Likely Redis caching middleware
SessionCheckerError::OtherError(err) => {
error!("SessionChecker other error: {:?}", err);
Self::ServerError
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -64,7 +64,7 @@ pub async fn create_character_handler(
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let user_session = match maybe_user_session {
@@ -97,7 +97,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let user_session = match maybe_user_session {
@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
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::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
@@ -10,9 +10,12 @@ use crate::http_server::endpoints::inference_job::utils::extractors::extract_liv
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web_lab::extract::Query;
use artcraft_api_defs::jobs::list_session_jobs::{ListSessionJobsItem, ListSessionJobsQueryParams, ListSessionJobsSuccessResponse, ListSessionRequestDetailsResponse, ListSessionResultDetailsResponse, ListSessionStatusDetailsResponse};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -31,7 +34,7 @@ use redis_common::redis_keys::RedisKeys;
use server_environment::ServerEnvironment;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::IntoParams;
use utoipa::{IntoParams, ToSchema};
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
@@ -39,6 +42,40 @@ use utoipa::IntoParams;
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS : u64 = 60 * 3;
#[derive(Debug, ToSchema)]
pub enum ListSessionJobsError {
ServerError,
NotFound,
NotAuthorized,
}
impl ResponseError for ListSessionJobsError {
fn status_code(&self) -> StatusCode {
match *self {
ListSessionJobsError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
ListSessionJobsError::NotFound => StatusCode::NOT_FOUND,
ListSessionJobsError::NotAuthorized => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
Self::ServerError => "server error".to_string(),
Self::NotFound => "not found".to_string(),
Self::NotAuthorized => "not authorized".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for ListSessionJobsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
/// List job statuses for jobs that are associated with the user's session.
///
/// The user must be logged in. This endpoint will only show the last 36 hours
@@ -52,8 +89,7 @@ const JOB_KEEPALIVE_TTL_SECONDS : u64 = 60 * 3;
path = "/v1/jobs/session",
responses(
(status = 200, body = ListSessionJobsSuccessResponse),
(status = 401, description = "Not authorized"),
(status = 500, description = "Server error"),
(status = 500, body = ListSessionJobsError),
),
params(
ListSessionJobsQueryParams
@@ -63,21 +99,29 @@ pub async fn list_session_jobs_handler(
http_request: HttpRequest,
query: Query<ListSessionJobsQueryParams>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<ListSessionJobsSuccessResponse>, AdvancedCommonWebError> {
) -> Result<Json<ListSessionJobsSuccessResponse>, ListSessionJobsError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
warn!("MySql pool error: {:?}", err);
ListSessionJobsError::ServerError
})?;
let maybe_avt_token = server_state.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== USER SESSION ==================== //
// NB: SessionCheckerError → AdvancedCommonWebError maps payload errors (bad cookie) to 401
// and DB / cache errors to 500.
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_extended_from_connection(&http_request, &mut mysql_connection)
.await?;
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
ListSessionJobsError::ServerError
})?;
let include_states = query.include_states
.as_deref()
@@ -91,14 +135,20 @@ pub async fn list_session_jobs_handler(
.filter_map(|status| JobStatusPlus::from_str(status).ok())
.collect::<HashSet<_>>());
let user = match (maybe_user_session.as_ref(), maybe_avt_token.as_ref()) {
(Some(session), _) => SessionUser::User(&session.user_token_typed),
(None, Some(avt_token)) => SessionUser::Anonymous(avt_token),
let user;
match (maybe_user_session.as_ref(), maybe_avt_token.as_ref()) {
(Some(session), _) => {
user = SessionUser::User(&session.user_token_typed);
}
(None, Some(avt_token)) => {
user = SessionUser::Anonymous(avt_token);
}
(None, None) => {
// TODO(bt,2025-04-15): We should install an AVT cookie.
return Err(AdvancedCommonWebError::NotAuthorized);
return Err(ListSessionJobsError::NotAuthorized);
}
};
}
let args = ListSessionJobsForUserArgs {
user,
@@ -107,11 +157,23 @@ pub async fn list_session_jobs_handler(
};
// NB: Since this is publicly exposed, we don't query sensitive data.
let records = list_session_jobs_from_connection(args, &mut mysql_connection).await?;
let maybe_records = list_session_jobs_from_connection(
args, &mut mysql_connection).await;
let records = match maybe_records {
Ok(records) => records,
Err(err) => {
error!("tts job query error: {:?}", err);
return Err(ListSessionJobsError::ServerError);
}
};
let mut redis = server_state.redis_pool
.get()
.map_err(AdvancedCommonWebError::from_error)?;
.map_err(|e| {
error!("redis error: {:?}", e);
ListSessionJobsError::ServerError
})?;
// TODO(bt,2024-04-22): Look up the extra redis statuses per item.
@@ -142,7 +204,7 @@ fn records_to_response(
records: Vec<GenericInferenceJobStatus>,
server_environment: ServerEnvironment,
media_domain: MediaDomain,
) -> Result<Json<ListSessionJobsSuccessResponse>, AdvancedCommonWebError> {
) -> Result<Json<ListSessionJobsSuccessResponse>, ListSessionJobsError> {
let mut records = records.into_iter()
.map(|record| {
db_record_to_response_payload(record, None, server_environment, media_domain)
@@ -81,7 +81,7 @@ pub async fn omni_gen_image_generate_handler(
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let user_token = match maybe_user_session.as_ref() {
@@ -81,7 +81,7 @@ pub async fn omni_gen_video_generate_handler(
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let user_token = match maybe_user_session.as_ref() {
@@ -3,26 +3,60 @@
#![forbid(unused_mut)]
#![forbid(unused_variables)]
use std::fmt;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::{web, HttpRequest, HttpResponse};
use log::warn;
use sqlx::MySqlPool;
use utoipa::ToSchema;
use artcraft_api_defs::users::session_info::{FakeYouPlan, SessionInfoSuccessResponse, SessionOnboardingState, SessionUserInfo, StorytellerStreamPlan};
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use http_server_common::response::response_error_helpers::to_simple_json_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::user_details_lite_builder::UserDetailsLightBuilder;
use crate::http_server::cookies::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::session::session_checker::SessionChecker;
#[derive(Debug, ToSchema)]
pub enum SessionInfoError {
ServerError,
}
impl ResponseError for SessionInfoError {
fn status_code(&self) -> StatusCode {
match *self {
SessionInfoError::ServerError=> StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
SessionInfoError::ServerError => "server error".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for SessionInfoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
#[utoipa::path(
get,
tag = "Users",
path = "/v1/session",
responses(
(status = 200, description = "Get profile", body = SessionInfoSuccessResponse),
(status = 500, description = "Server error"),
(status = 500, description = "Server error", body = SessionInfoError),
),
)]
pub async fn session_info_handler(
@@ -30,85 +64,100 @@ pub async fn session_info_handler(
mysql_pool: web::Data<MySqlPool>,
session_checker: web::Data<SessionChecker>,
avt_manager: web::Data<AvtCookieManager>,
) -> Result<HttpResponse, AdvancedCommonWebError>
) -> Result<HttpResponse, SessionInfoError>
{
let mut mysql_connection = mysql_pool.acquire().await?;
let mut mysql_connection = mysql_pool.acquire()
.await
.map_err(|e| {
warn!("Could not acquire DB pool: {:?}", e);
SessionInfoError::ServerError
})?;
let maybe_user_session = session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|err| {
warn!("Error checking session: {:?}", err);
err
.map_err(|e| {
warn!("Session checker error: {:?}", e);
SessionInfoError::ServerError
})?;
let mut logged_in = false;
let mut user_info = None;
if let Some(session_data) = maybe_user_session {
let feature_flags =
UserSessionFeatureFlags::new(session_data.maybe_feature_flags.as_deref());
match maybe_user_session {
None => {}
Some(session_data) => {
let feature_flags =
UserSessionFeatureFlags::new(session_data.maybe_feature_flags.as_deref());
if !session_data.is_banned {
// NB: Banned users can't be logged in
logged_in = true;
user_info = Some(SessionUserInfo {
core_info: UserDetailsLightBuilder::from_db_fields(
&session_data.user_token,
&session_data.username,
&session_data.display_name,
&session_data.email_gravatar_hash,
),
onboarding: SessionOnboardingState {
email_not_set: session_data.email_is_synthetic,
email_not_confirmed: !(session_data.email_confirmed || session_data.email_confirmed_by_google),
password_not_set: session_data.is_without_password,
username_not_customized: session_data.username_is_not_customized,
},
user_token: session_data.user_token,
username: session_data.username.to_string(),
display_name: session_data.display_name.to_string(),
email_gravatar_hash: session_data.email_gravatar_hash.to_string(),
if !session_data.is_banned {
// NB: Banned users can't be logged in
logged_in = true;
user_info = Some(SessionUserInfo {
core_info: UserDetailsLightBuilder::from_db_fields(
&session_data.user_token,
&session_data.username,
&session_data.display_name,
&session_data.email_gravatar_hash,
),
onboarding: SessionOnboardingState {
email_not_set: session_data.email_is_synthetic,
email_not_confirmed: !(session_data.email_confirmed || session_data.email_confirmed_by_google),
password_not_set: session_data.is_without_password,
username_not_customized: session_data.username_is_not_customized,
},
user_token: session_data.user_token,
username: session_data.username.to_string(),
display_name: session_data.display_name.to_string(),
email_gravatar_hash: session_data.email_gravatar_hash.to_string(),
// Rollout / feature flags:
can_access_studio: feature_flags.has_flag(UserFeatureFlag::Studio),
maybe_feature_flags: feature_flags.clone_flags(),
// Rollout / feature flags:
can_access_studio: feature_flags.has_flag(UserFeatureFlag::Studio),
maybe_feature_flags: feature_flags.clone_flags(),
// Premium plans:
fakeyou_plan: FakeYouPlan::Free,
storyteller_stream_plan: StorytellerStreamPlan::Free,
// Premium plans:
fakeyou_plan: FakeYouPlan::Free,
storyteller_stream_plan: StorytellerStreamPlan::Free,
// Usage permissions:
can_use_tts: session_data.can_use_tts,
can_use_w2l: session_data.can_use_w2l,
can_delete_own_tts_results: session_data.can_delete_own_tts_results,
can_delete_own_w2l_results: session_data.can_delete_own_w2l_results,
can_delete_own_account: session_data.can_delete_own_account,
// Usage permissions:
can_use_tts: session_data.can_use_tts,
can_use_w2l: session_data.can_use_w2l,
can_delete_own_tts_results: session_data.can_delete_own_tts_results,
can_delete_own_w2l_results: session_data.can_delete_own_w2l_results,
can_delete_own_account: session_data.can_delete_own_account,
// Contribution permissions:
can_upload_tts_models: session_data.can_upload_tts_models,
can_upload_w2l_templates: session_data.can_upload_w2l_templates,
can_delete_own_tts_models: session_data.can_delete_own_tts_models,
can_delete_own_w2l_templates: session_data.can_delete_own_w2l_templates,
// Contribution permissions:
can_upload_tts_models: session_data.can_upload_tts_models,
can_upload_w2l_templates: session_data.can_upload_w2l_templates,
can_delete_own_tts_models: session_data.can_delete_own_tts_models,
can_delete_own_w2l_templates: session_data.can_delete_own_w2l_templates,
// Moderation permissions:
can_approve_w2l_templates: session_data.can_approve_w2l_templates,
can_edit_other_users_profiles: session_data.can_edit_other_users_profiles,
can_edit_other_users_tts_models: session_data.can_edit_other_users_tts_models,
can_edit_other_users_w2l_templates: session_data.can_edit_other_users_w2l_templates,
can_delete_other_users_tts_models: session_data.can_delete_other_users_tts_models,
can_delete_other_users_tts_results: session_data.can_delete_other_users_tts_results,
can_delete_other_users_w2l_templates: session_data.can_delete_other_users_w2l_templates,
can_delete_other_users_w2l_results: session_data.can_delete_other_users_w2l_results,
can_ban_users: session_data.can_ban_users,
can_delete_users: session_data.can_delete_users,
});
// Moderation permissions:
can_approve_w2l_templates: session_data.can_approve_w2l_templates,
can_edit_other_users_profiles: session_data.can_edit_other_users_profiles,
can_edit_other_users_tts_models: session_data.can_edit_other_users_tts_models,
can_edit_other_users_w2l_templates: session_data.can_edit_other_users_w2l_templates,
can_delete_other_users_tts_models: session_data.can_delete_other_users_tts_models,
can_delete_other_users_tts_results: session_data.can_delete_other_users_tts_results,
can_delete_other_users_w2l_templates: session_data.can_delete_other_users_w2l_templates,
can_delete_other_users_w2l_results: session_data.can_delete_other_users_w2l_results,
can_ban_users: session_data.can_ban_users,
can_delete_users: session_data.can_delete_users,
});
}
}
}
let maybe_avt_cookie = match avt_manager.decode_cookie_payload_from_request(&http_request) {
Ok(Some(_avt_cookie)) => None,
_ => Some(avt_manager.make_new_cookie()?),
_ => {
let cookie = avt_manager.make_new_cookie()
.map_err(|e| {
warn!("avt cookie creation error: {:?}", e);
SessionInfoError::ServerError
})?;
Some(cookie)
}
};
let response = SessionInfoSuccessResponse {
@@ -117,7 +166,8 @@ pub async fn session_info_handler(
user: user_info,
};
let body = serde_json::to_string(&response)?;
let body = serde_json::to_string(&response)
.map_err(|_e| SessionInfoError::ServerError)?;
let mut response_builder = HttpResponse::Ok();
@@ -1,3 +1,2 @@
pub mod lookup;
pub mod session_checker;
pub mod session_checker_error;
pub mod session_checker;
@@ -5,6 +5,7 @@
use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager;
use actix_web::HttpRequest;
use errors::AnyhowResult;
use log::warn;
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};
@@ -16,7 +17,6 @@ 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;
use crate::http_server::session::session_checker_error::SessionCheckerError;
#[derive(Clone)]
pub struct SessionChecker {
@@ -40,7 +40,7 @@ impl SessionChecker {
}
}
pub fn get_session_token(&self, request: &HttpRequest) -> Result<Option<String>, SessionCheckerError> {
pub fn get_session_token(&self, request: &HttpRequest) -> AnyhowResult<Option<String>> {
Ok(self.cookie_manager.decode_session_payload_from_request(request)?
.map(|payload| payload.session_token))
}
@@ -56,7 +56,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
pool: &MySqlPool
) -> Result<Option<SessionRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionRecord>>
{
self.do_session_light_lookup_and_cookie_decode(request, pool).await
}
@@ -66,7 +66,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Option<SessionRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionRecord>>
{
self.do_session_light_lookup_and_cookie_decode(request, &mut **mysql_connection).await
}
@@ -76,7 +76,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
mysql_executor: E,
) -> Result<Option<SessionRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionRecord>>
where E: 'e + Executor<'c, Database = MySql>
{
let maybe_session_token = self.cookie_manager.decode_session_payload_from_request(request)?
@@ -95,23 +95,18 @@ impl SessionChecker {
&self,
mysql_executor: E,
session_token: &str,
) -> Result<Option<SessionRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionRecord>>
where E: 'e + Executor<'c, Database = MySql>
{
match self.maybe_get_redis_cache_connection() {
None => {
Ok(get_user_session_by_token_light(mysql_executor, session_token).await?)
get_user_session_by_token_light(mysql_executor, session_token).await
}
Some(mut redis_ttl_cache) => {
let cache_key = RedisCacheKeys::session_record_light(session_token);
// NB: Redis cache requires AnyhowResult; convert sqlx::Error to anyhow inside the closure,
// then map any error from the cache layer back to SessionCheckerError::OtherError.
let result = redis_ttl_cache.lazy_load_if_not_cached(&cache_key, move || async move {
redis_ttl_cache.lazy_load_if_not_cached(&cache_key, move || {
get_user_session_by_token_light(mysql_executor, session_token)
.await
.map_err(anyhow::Error::from)
}).await;
result.map_err(SessionCheckerError::OtherError)
}).await
}
}
}
@@ -124,7 +119,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
pool: &MySqlPool,
) -> Result<Option<SessionUserRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionUserRecord>>
{
self.do_user_session_lookup_and_cookie_decode(request, pool).await
}
@@ -134,7 +129,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Option<SessionUserRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionUserRecord>>
{
self.do_user_session_lookup_and_cookie_decode(request, &mut **mysql_connection).await
}
@@ -144,7 +139,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
mysql_executor: E,
) -> Result<Option<SessionUserRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionUserRecord>>
where E: 'e + Executor<'c, Database = MySql>
{
let session_token = match self.get_session_token(request)? {
@@ -160,21 +155,18 @@ impl SessionChecker {
&self,
mysql_executor: E,
session_token: &str,
) -> Result<Option<SessionUserRecord>, SessionCheckerError>
) -> AnyhowResult<Option<SessionUserRecord>>
where E: 'e + Executor<'c, Database = MySql>
{
match self.maybe_get_redis_cache_connection() {
None => {
Ok(get_user_session_by_token(mysql_executor, session_token).await?)
get_user_session_by_token(mysql_executor, session_token).await
}
Some(mut redis_ttl_cache) => {
let cache_key = RedisCacheKeys::session_record_user(session_token);
let result = redis_ttl_cache.lazy_load_if_not_cached(&cache_key, move || async move {
redis_ttl_cache.lazy_load_if_not_cached(&cache_key, move || {
get_user_session_by_token(mysql_executor, session_token)
.await
.map_err(anyhow::Error::from)
}).await;
result.map_err(SessionCheckerError::OtherError)
}).await
}
}
}
@@ -187,7 +179,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
pool: &MySqlPool,
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
) -> AnyhowResult<Option<UserSessionExtended>>
{
let mut connection = pool.acquire().await?;
self.maybe_get_user_session_extended_from_connection(request, &mut connection).await
@@ -197,7 +189,7 @@ impl SessionChecker {
&self,
request: &HttpRequest,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
) -> AnyhowResult<Option<UserSessionExtended>>
{
let session_payload= match self.cookie_manager.decode_session_payload_from_request(request)? {
None => return Ok(None),
@@ -217,8 +209,7 @@ impl SessionChecker {
list_active_user_subscriptions(
mysql_connection,
user_session.user_token.as_str()
).await
.map_err(SessionCheckerError::OtherError)?;
).await?;
Ok(Some(UserSessionExtended {
user_token: user_session.user_token.as_str().to_string(),
@@ -292,3 +283,4 @@ impl SessionChecker {
result.flatten()
}
}
@@ -1,49 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use actix_artcraft::sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
#[derive(Debug)]
pub enum SessionCheckerError {
/// Error decoding or verifying the session.
/// Rate of failures can point to the cause:
/// - One-off errors: probably session forgeries
/// - Lots of errors: maybe the wrong hmac secret
SessionPayload(HttpUserSessionPayloadError),
/// Error looking up the session from the database.
Sqlx(sqlx::Error),
/// Some other kind of error - perhaps with the Redis middleware.
OtherError(anyhow::Error),
}
impl Display for SessionCheckerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SessionPayload(e) => write!(f, "session payload error: {}", e),
Self::Sqlx(e) => write!(f, "sqlx error: {}", e),
Self::OtherError(e) => write!(f, "other error: {}", e),
}
}
}
impl Error for SessionCheckerError {}
impl From<HttpUserSessionPayloadError> for SessionCheckerError {
fn from(err: HttpUserSessionPayloadError) -> Self {
Self::SessionPayload(err)
}
}
impl From<sqlx::Error> for SessionCheckerError {
fn from(err: sqlx::Error) -> Self {
Self::Sqlx(err)
}
}
impl From<anyhow::Error> for SessionCheckerError {
fn from(err: anyhow::Error) -> Self {
Self::OtherError(err)
}
}
@@ -0,0 +1,40 @@
import { ApiManager, ApiResponse } from "./ApiManager";
export interface NewVersionInfo {
version_label: string;
features_description: string;
direct_download_link?: string;
website_download_link: string;
}
export interface VersionInfoResponse {
is_up_to_date: boolean;
new_version?: NewVersionInfo;
}
export class VersionApi extends ApiManager {
private static instance: VersionApi;
public static getInstance(): VersionApi {
if (!VersionApi.instance) {
VersionApi.instance = new VersionApi();
}
return VersionApi.instance;
}
public getVersionInfo(
platform: string,
versionString: string,
): Promise<ApiResponse<VersionInfoResponse>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/artcraft/version_info/${platform}/${versionString}`;
return this.get<VersionInfoResponse>({ endpoint })
.then((data) => ({
success: true,
data,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
}
@@ -17,3 +17,4 @@ export * from "./UserBookmarksApi";
export * from "./UsersApi";
export * from "./VideoApi";
export * from "./WeightsApi";
export * from "./VersionApi";
@@ -0,0 +1,184 @@
import {
faArrowAltUp,
faTriangleExclamation,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { gtagEvent } from "@storyteller/google-analytics";
import { GetAppInfo, OpenUrl } from "@storyteller/tauri-api";
import { useTauriPlatform } from "@storyteller/tauri-utils";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { MiscApi, VersionApi, NewVersionInfo } from "~/Classes/ApiManager";
export const AppStatusCheck = () => {
const [updateInfo, setUpdateInfo] = useState<NewVersionInfo | null>(null);
const platform = useTauriPlatform();
useEffect(() => {
let active = true;
const checkAppStatus = async () => {
try {
const appInfo = await GetAppInfo();
const version = appInfo.payload.artcraft_version;
const currentPlatform =
platform || appInfo.payload.os_platform || "windows";
let versionRes = await VersionApi.getInstance().getVersionInfo(
currentPlatform,
version,
);
// TODO: Remove this mock once the API is working!
if (!versionRes.success || !versionRes.data) {
console.log(
"API not working - injecting mock version info for testing",
);
versionRes = {
success: true,
data: {
is_up_to_date: false,
new_version: {
version_label: "v1.5.0-beta",
features_description:
"New AI generation tools, UI polish, and bug fixes!",
direct_download_link: "https://getartcraft.com/download/direct",
website_download_link: "https://getartcraft.com/download",
},
},
};
}
if (
active &&
versionRes.success &&
versionRes.data &&
!versionRes.data.is_up_to_date &&
versionRes.data.new_version
) {
const newVersion = versionRes.data.new_version;
setUpdateInfo(newVersion);
setTimeout(() => {
if (active) {
toast(
(t) => (
<div
className="cursor-pointer"
onClick={() => {
toast.dismiss(t.id);
if (newVersion.direct_download_link) {
OpenUrl(newVersion.direct_download_link);
} else {
OpenUrl(newVersion.website_download_link);
}
gtagEvent("click_update_button");
}}
>
Update available: {newVersion.version_label}
</div>
),
{
duration: 10000,
icon: (
<FontAwesomeIcon
icon={faArrowAltUp}
className="text-blue-600"
/>
),
style: {
background: "#eff6ff",
color: "#1e3a8a",
border: "1px solid #bfdbfe",
},
},
);
}
}, 1000);
}
let outageRes = await new MiscApi().GetStatusAlertCheck();
// TODO: Remove this mock once the API is working!
if (!outageRes.success || !outageRes.data?.maybe_alert?.maybe_message) {
console.log(
"API not working - injecting mock outage info for testing",
);
outageRes = {
success: true,
data: {
refresh_interval_millis: 60000,
maybe_alert: {
maybe_category: "outage",
maybe_message:
"We are currently experiencing a brief outage. The team is actively investigating.",
},
},
};
}
if (
active &&
outageRes.success &&
outageRes.data?.maybe_alert?.maybe_message
) {
// Add a small delay for the toast to ensure it doesn't get hidden by initial loads
setTimeout(() => {
if (active) {
toast(outageRes.data!.maybe_alert!.maybe_message!, {
duration: 15000,
icon: (
<FontAwesomeIcon
icon={faTriangleExclamation}
className="text-red-700"
/>
),
style: {
background: "#fef2f2",
color: "#991b1b",
border: "1px solid #f87171",
},
});
}
}, 1000);
}
} catch (err) {
console.error("Failed to check app status", err);
}
};
checkAppStatus();
return () => {
active = false;
};
}, [platform]);
if (!updateInfo) {
return null;
}
return (
<Tooltip
content={`Update: ${updateInfo.version_label} - ${updateInfo.features_description}`}
position="bottom"
delay={300}
>
<Button
variant="primary"
className="h-[38px] transition-all duration-300 hover:animate-none hover:shadow-none"
onClick={() => {
if (updateInfo.direct_download_link) {
OpenUrl(updateInfo.direct_download_link);
} else {
OpenUrl(updateInfo.website_download_link);
}
gtagEvent("click_update_button");
}}
>
<FontAwesomeIcon icon={faArrowAltUp} className="animate-bounce" />
Update
</Button>
</Tooltip>
);
};
@@ -76,6 +76,7 @@ import { AppsQuickMenu } from "./AppsQuickMenu";
import { SceneTitleInput } from "./SceneTitleInput";
import { TaskQueue } from "./TaskQueue";
import { UploadImagesButton } from "./UploadImagesButton";
import { AppStatusCheck } from "./AppStatusCheck";
interface Props {
pageName: string;
@@ -166,7 +167,7 @@ export const TopBar = ({ pageName }: Props) => {
Promise.all([
creditsStore.fetchFromServer(),
subscriptionStore.fetchFromServer(),
])
]);
}, 1000);
return () => clearTimeout(t);
}, []);
@@ -597,6 +598,8 @@ export const TopBar = ({ pageName }: Props) => {
)}
</div>
<AppStatusCheck />
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5">
{isCreditsLoading ? (
@@ -607,7 +610,10 @@ export const TopBar = ({ pageName }: Props) => {
position="bottom"
align="center"
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary" />
<FontAwesomeIcon
icon={faCoins}
className="text-primary"
/>
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
@@ -8,6 +8,7 @@ export type TabId =
| "EDIT"
| "IMAGE"
| "APPS"
| "EDIT"
| "VIDEO_FRAME_EXTRACTOR"
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL"