combined query

This commit is contained in:
Brandon Thomas committed 2026-07-06 15:58:55 -04:00
1 parent 4fbe9f54e0
commit aba271e413
12 files changed
+376 -20

No files matched your search

@@ -0,0 +1,21 @@
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
/// A user authenticated by either an API key or a web session cookie.
///
/// The two lookup queries (`get_api_or_web_session_user_by_api_key` and
/// `get_api_or_web_session_user_by_session_token`) select congruent user fields so both can
/// produce this one uniform record.
pub struct ApiOrWebSessionUserRecord {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_address: String,
pub user_role_slug: String,
pub is_banned: bool,
pub can_ban_users: bool,
/// Only set when the lookup was by API key.
pub maybe_api_key_token: Option<ApiKeyToken>,
}
@@ -0,0 +1,70 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use artcraft_api_keys::ArtcraftApiKey;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false};
use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
pub struct GetApiOrWebSessionUserByApiKeyArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The actual API key secret presented by the client.
pub api_key: &'e ArtcraftApiKey,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up the user behind an API key secret, selecting the same user fields as
/// `get_api_or_web_session_user_by_session_token` so both auth paths share one record shape.
///
/// Returns `Ok(None)` when no row matches, when the matching API key is soft-deleted, or when the
/// owning user is missing or deleted. The full secret is never echoed back.
pub async fn get_api_or_web_session_user_by_api_key<'e, 'c: 'e, E>(
args: GetApiOrWebSessionUserByApiKeyArgs<'e, 'c, E>,
) -> Result<Option<ApiOrWebSessionUserRecord>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
users.token as `user_token: UserToken`,
users.username,
users.display_name,
users.email_address,
users.user_role_slug,
users.is_banned,
user_roles.can_ban_users,
api_keys.token as `api_key_token: ApiKeyToken`
FROM api_keys
INNER JOIN users
ON users.token = api_keys.owner_user_token
LEFT OUTER JOIN user_roles
ON users.user_role_slug = user_roles.slug
WHERE api_keys.api_key = ?
AND api_keys.maybe_deleted_at IS NULL
AND users.user_deleted_at IS NULL
AND users.mod_deleted_at IS NULL
LIMIT 1
"#,
args.api_key.as_str_be_careful(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| ApiOrWebSessionUserRecord {
user_token: r.user_token,
username: r.username,
display_name: r.display_name,
email_address: r.email_address,
user_role_slug: r.user_role_slug,
is_banned: i8_to_bool(r.is_banned),
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
maybe_api_key_token: Some(r.api_key_token),
}))
}
@@ -0,0 +1,67 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false};
use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
pub struct GetApiOrWebSessionUserBySessionTokenArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The session token decoded from the session cookie.
pub session_token: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up the user behind a web session token, selecting the same user fields as
/// `get_api_or_web_session_user_by_api_key` so both auth paths share one record shape.
///
/// Returns `Ok(None)` when no row matches, when the session is deleted, or when the owning user
/// is missing or deleted.
pub async fn get_api_or_web_session_user_by_session_token<'e, 'c: 'e, E>(
args: GetApiOrWebSessionUserBySessionTokenArgs<'e, 'c, E>,
) -> Result<Option<ApiOrWebSessionUserRecord>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
users.token as `user_token: UserToken`,
users.username,
users.display_name,
users.email_address,
users.user_role_slug,
users.is_banned,
user_roles.can_ban_users
FROM user_sessions
INNER JOIN users
ON users.token = user_sessions.user_token
LEFT OUTER JOIN user_roles
ON users.user_role_slug = user_roles.slug
WHERE user_sessions.token = ?
AND user_sessions.deleted_at IS NULL
AND users.user_deleted_at IS NULL
AND users.mod_deleted_at IS NULL
LIMIT 1
"#,
args.session_token,
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| ApiOrWebSessionUserRecord {
user_token: r.user_token,
username: r.username,
display_name: r.display_name,
email_address: r.email_address,
user_role_slug: r.user_role_slug,
is_banned: i8_to_bool(r.is_banned),
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
maybe_api_key_token: None,
}))
}
@@ -0,0 +1,3 @@
pub mod api_or_web_session_user_record;
pub mod get_api_or_web_session_user_by_api_key;
pub mod get_api_or_web_session_user_by_session_token;
@@ -1,3 +1,4 @@
pub mod api_or_web_sessions;
pub mod user;
pub mod user_badges;
pub mod user_bookmarks;
@@ -24,6 +24,7 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
@@ -58,19 +59,12 @@ pub async fn create_character_handler(
.acquire()
.await?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::from(e)
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => return Err(CommonWebError::NotAuthorized),
};
let user_session = require_api_or_web_session(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&mut mysql_connection,
).await?;
let user_token = &user_session.user_token;
let ip_address = get_request_ip(&http_request);
@@ -194,7 +188,7 @@ pub async fn create_character_handler(
kinovi_character_id: &gen_result.character_id,
maybe_model_type: None,
maybe_creator_user_token: Some(user_token),
maybe_avt_token: None,
maybe_avt_token: user_session.maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Hidden,
mysql_executor: &mut *transaction,
@@ -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::user_lookup::user_session::require_user_session::require_user_session;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
use crate::state::server_state::ServerState;
/// Delete a character.
@@ -40,7 +40,12 @@ 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_api_or_web_session(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&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::user_lookup::user_session::require_user_session::require_user_session;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
use crate::state::server_state::ServerState;
/// Edit a character's name or description.
@@ -38,7 +38,12 @@ 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_api_or_web_session(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&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::user_lookup::user_session::require_user_session::require_user_session;
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
use crate::state::server_state::ServerState;
/// List characters for the current session.
@@ -39,7 +39,12 @@ 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_api_or_web_session(
&http_request,
&server_state.session_checker,
&server_state.avt_cookie_manager,
&mut mysql_connection,
).await?;
let user_token = &user_session.user_token;
@@ -0,0 +1 @@
pub mod require_api_or_web_session;
@@ -0,0 +1,183 @@
use std::marker::PhantomData;
use actix_web::http::header;
use actix_web::HttpRequest;
use log::warn;
use sqlx::MySqlConnection;
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use mysql_queries::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_api_key::{
get_api_or_web_session_user_by_api_key, GetApiOrWebSessionUserByApiKeyArgs,
};
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_session_token::{
get_api_or_web_session_user_by_session_token, GetApiOrWebSessionUserBySessionTokenArgs,
};
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key;
const ADMIN_ROLE_SLUG: &str = "admin";
/// The authenticated identity behind a request that may come from either an API client
/// (`Authorization` header API key) or the website (session cookie). Fields common to both auth
/// paths are populated uniformly, so handlers don't need to care which path authenticated the
/// user.
pub struct ApiOrWebSession {
pub session_type: SessionType,
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_address: String,
pub user_role_slug: String,
pub can_ban_users: bool,
/// Only present for API-key sessions.
pub maybe_api_key_token: Option<ApiKeyToken>,
/// Anonymous visitor tracking cookie. Only ever present for web sessions.
pub maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
}
/// Which auth path authenticated the request.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum SessionType {
Api,
WebSession,
}
impl ApiOrWebSession {
pub fn is_mod(&self) -> bool {
self.can_ban_users || self.user_role_slug == ADMIN_ROLE_SLUG
}
}
/// Authenticate a request as either an API-key user or a web-session (cookie) user.
///
/// If an `Authorization` header is present, ONLY the API-key lookup is attempted — a malformed or
/// unknown key is a 401, never a fallthrough to cookies. Otherwise the session cookie is decoded
/// and looked up, and the AVT (anonymous visitor tracking) cookie is captured alongside it.
///
/// Neither path is cached: every request performs a fresh MySQL lookup. Pass an already-open
/// connection (`&mut *connection`) rather than acquiring a fresh one.
pub async fn require_api_or_web_session(
http_request: &HttpRequest,
session_checker: &SessionChecker,
avt_cookie_manager: &AvtCookieManager,
mysql_connection: &mut MySqlConnection,
) -> Result<ApiOrWebSession, CommonWebError> {
if http_request.headers().contains_key(header::AUTHORIZATION) {
authenticate_by_api_key(http_request, mysql_connection).await
} else {
authenticate_by_web_session(http_request, session_checker, avt_cookie_manager, mysql_connection).await
}
}
async fn authenticate_by_api_key(
http_request: &HttpRequest,
mysql_connection: &mut MySqlConnection,
) -> Result<ApiOrWebSession, CommonWebError> {
let api_key = match get_authorization_header_api_key(http_request) {
Some(api_key) => api_key,
None => {
warn!("Authorization header present but not a usable API key");
return Err(CommonWebError::NotAuthorized);
}
};
let maybe_record = get_api_or_web_session_user_by_api_key(GetApiOrWebSessionUserByApiKeyArgs {
api_key: &api_key,
mysql_executor: &mut *mysql_connection,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("API key user lookup error: {:?}", err);
CommonWebError::from_error(err)
})?;
// A missing or soft-deleted key (or a key whose owner no longer exists) is a 401, not a leak of
// which case occurred.
let record = match maybe_record {
Some(record) => record,
None => {
warn!("No live API key user for presented key: {:?}", api_key);
return Err(CommonWebError::NotAuthorized);
}
};
into_session_rejecting_banned(record, SessionType::Api, None)
}
async fn authenticate_by_web_session(
http_request: &HttpRequest,
session_checker: &SessionChecker,
avt_cookie_manager: &AvtCookieManager,
mysql_connection: &mut MySqlConnection,
) -> Result<ApiOrWebSession, CommonWebError> {
let maybe_session_token = session_checker
.get_session_token(http_request)
.map_err(|err| {
warn!("Session cookie decode error: {:?}", err);
CommonWebError::from_error(err)
})?;
let session_token = match maybe_session_token {
Some(session_token) => session_token,
None => {
warn!("not logged in");
return Err(CommonWebError::NotAuthorized);
}
};
let maybe_record = get_api_or_web_session_user_by_session_token(GetApiOrWebSessionUserBySessionTokenArgs {
session_token: &session_token,
mysql_executor: &mut *mysql_connection,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Web session user lookup error: {:?}", err);
CommonWebError::from_error(err)
})?;
let record = match maybe_record {
Some(record) => record,
None => {
warn!("Valid cookie; invalid session: {}", session_token);
return Err(CommonWebError::NotAuthorized);
}
};
let maybe_avt_token = avt_cookie_manager.get_avt_token_from_request(http_request);
into_session_rejecting_banned(record, SessionType::WebSession, maybe_avt_token)
}
fn into_session_rejecting_banned(
record: ApiOrWebSessionUserRecord,
session_type: SessionType,
maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
) -> Result<ApiOrWebSession, CommonWebError> {
if record.is_banned {
warn!("user is banned: {:?}", record.user_token.as_str());
return Err(CommonWebError::NotAuthorized);
}
Ok(ApiOrWebSession {
session_type,
user_token: record.user_token,
username: record.username,
display_name: record.display_name,
email_address: record.email_address,
user_role_slug: record.user_role_slug,
can_ban_users: record.can_ban_users,
maybe_api_key_token: record.maybe_api_key_token,
maybe_avt_token,
})
}
@@ -1,2 +1,3 @@
pub mod api_keys;
pub mod api_or_web_session;
pub mod user_session;