From d99eef6b8d4d90df566234b08fc80213bf8ebc28 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 5 May 2024 01:47:37 -0400 Subject: [PATCH] upsert and delete featured items (not yet routed or documented) --- _sql/migrations_squashed/featured_items.sql | 40 ++++ .../featured_items/featured_item_entity.rs | 47 +++++ .../src/by_table/featured_items/mod.rs | 1 + .../composite_identifiers/src/by_table/mod.rs | 1 + .../audit_logs/audit_log_entity_action.rs | 18 ++ .../featured_item_entity_type.rs | 125 +++++++++++ .../enums/src/by_table/featured_items/mod.rs | 1 + crates/_types/enums/src/by_table/mod.rs | 1 + .../featured_items/delete_featured_item.rs | 32 +++ .../src/queries/featured_items/mod.rs | 2 + .../featured_items/upsert_featured_item.rs | 51 +++++ crates/lib/mysql_queries/src/queries/mod.rs | 1 + .../user_bookmark_entity_token.rs | 2 + .../create_featured_item_handler.rs | 197 ++++++++++++++++++ .../delete_featured_item_handler.rs | 186 +++++++++++++++++ .../endpoints/featured_items/mod.rs | 2 + .../src/http_server/endpoints/mod.rs | 3 +- 17 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 _sql/migrations_squashed/featured_items.sql create mode 100644 crates/_types/composite_identifiers/src/by_table/featured_items/featured_item_entity.rs create mode 100644 crates/_types/composite_identifiers/src/by_table/featured_items/mod.rs create mode 100644 crates/_types/enums/src/by_table/featured_items/featured_item_entity_type.rs create mode 100644 crates/_types/enums/src/by_table/featured_items/mod.rs create mode 100644 crates/lib/mysql_queries/src/queries/featured_items/delete_featured_item.rs create mode 100644 crates/lib/mysql_queries/src/queries/featured_items/mod.rs create mode 100644 crates/lib/mysql_queries/src/queries/featured_items/upsert_featured_item.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/mod.rs diff --git a/_sql/migrations_squashed/featured_items.sql b/_sql/migrations_squashed/featured_items.sql new file mode 100644 index 0000000000..ae9436d156 --- /dev/null +++ b/_sql/migrations_squashed/featured_items.sql @@ -0,0 +1,40 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE featured_items ( + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- ========== ENTITY LINKAGE ========== + + -- The type of record this is attached to + -- Currently supported values: + -- * 'model_weight' + -- * 'media_file' + entity_type VARCHAR(32) NOT NULL, + + -- The token for the record. + entity_token VARCHAR(32) NOT NULL, + + -- ========== SORTING ========== + + -- A sorted ordering (optional) for pagination + maybe_sort_order INT UNSIGNED DEFAULT NULL, + + -- ========== TIMESTAMPS ========== + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (entity_type, entity_token), + + KEY index_maybe_sort_order_asc (maybe_sort_order ASC), + KEY index_maybe_sort_order_desc (maybe_sort_order DESC), + + KEY index_created_at (created_at), + KEY index_deleted_at (deleted_at) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/crates/_types/composite_identifiers/src/by_table/featured_items/featured_item_entity.rs b/crates/_types/composite_identifiers/src/by_table/featured_items/featured_item_entity.rs new file mode 100644 index 0000000000..cb3cd42337 --- /dev/null +++ b/crates/_types/composite_identifiers/src/by_table/featured_items/featured_item_entity.rs @@ -0,0 +1,47 @@ +use enums::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::users::UserToken; + +// TODO(bt, 2024-01-16): A ref type with <'a> lifetime of inner data instead of ownership? +pub enum FeaturedItemEntity { + MediaFile(MediaFileToken), + ModelWeight(ModelWeightToken), + User(UserToken), +} + +// TODO(bt, 2024-01-16): Codegen? Make traits for these? Maybe overkill. +impl FeaturedItemEntity { + + pub fn from_entity_type_and_token(entity_type: FeaturedItemEntityType, token: &str) -> Self { + match entity_type { + FeaturedItemEntityType::ModelWeight => Self::ModelWeight(ModelWeightToken::new_from_str(token)), + FeaturedItemEntityType::MediaFile => Self::MediaFile(MediaFileToken::new_from_str(token)), + FeaturedItemEntityType::User => Self::User(UserToken::new_from_str(token)), + } + } + + pub fn get_entity_type(&self) -> FeaturedItemEntityType { + match self { + Self::MediaFile(_) => FeaturedItemEntityType::MediaFile, + Self::ModelWeight(_) => FeaturedItemEntityType::ModelWeight, + Self::User(_) => FeaturedItemEntityType::User, + } + } + + pub fn get_entity_token_str(&self) -> &str { + match self { + Self::MediaFile(token) => token.as_str(), + Self::ModelWeight(token) => token.as_str(), + Self::User(token) => token.as_str(), + } + } + + pub fn get_composite_keys(&self) -> (FeaturedItemEntityType, &str) { + match self { + Self::MediaFile(token) => (FeaturedItemEntityType::MediaFile, token.as_str()), + Self::ModelWeight(token) => (FeaturedItemEntityType::ModelWeight, token.as_str()), + Self::User(token) => (FeaturedItemEntityType::User, token.as_str()), + } + } +} diff --git a/crates/_types/composite_identifiers/src/by_table/featured_items/mod.rs b/crates/_types/composite_identifiers/src/by_table/featured_items/mod.rs new file mode 100644 index 0000000000..9e0befc627 --- /dev/null +++ b/crates/_types/composite_identifiers/src/by_table/featured_items/mod.rs @@ -0,0 +1 @@ +pub mod featured_item_entity; \ No newline at end of file diff --git a/crates/_types/composite_identifiers/src/by_table/mod.rs b/crates/_types/composite_identifiers/src/by_table/mod.rs index 390d881f49..1bd660c9bf 100644 --- a/crates/_types/composite_identifiers/src/by_table/mod.rs +++ b/crates/_types/composite_identifiers/src/by_table/mod.rs @@ -1,2 +1,3 @@ pub mod audit_logs; pub mod batch_generations; +pub mod featured_items; diff --git a/crates/_types/enums/src/by_table/audit_logs/audit_log_entity_action.rs b/crates/_types/enums/src/by_table/audit_logs/audit_log_entity_action.rs index 21a4688a16..b047175eb9 100644 --- a/crates/_types/enums/src/by_table/audit_logs/audit_log_entity_action.rs +++ b/crates/_types/enums/src/by_table/audit_logs/audit_log_entity_action.rs @@ -24,6 +24,14 @@ pub enum AuditLogEntityAction { /// Delete action #[serde(rename = "delete")] Delete, + + /// Create featured item + #[serde(rename = "featured_item_create")] + FeaturedItemCreate, + + /// Delete featured item + #[serde(rename = "featured_item_delete")] + FeaturedItemDelete, } // TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! @@ -38,6 +46,8 @@ impl AuditLogEntityAction { Self::Edit => "edit", Self::EditFeatures => "edit_features", Self::Delete => "delete", + Self::FeaturedItemCreate => "featured_item_create", + Self::FeaturedItemDelete => "featured_item_delete", } } @@ -47,6 +57,8 @@ impl AuditLogEntityAction { "edit" => Ok(Self::Edit), "edit_features" => Ok(Self::EditFeatures), "delete" => Ok(Self::Delete), + "featured_item_create" => Ok(Self::FeaturedItemCreate), + "featured_item_delete" => Ok(Self::FeaturedItemDelete), _ => Err(format!("invalid value: {:?}", value)), } } @@ -66,6 +78,8 @@ mod tests { assert_serialization(AuditLogEntityAction::Edit, "edit"); assert_serialization(AuditLogEntityAction::EditFeatures, "edit_features"); assert_serialization(AuditLogEntityAction::Delete, "delete"); + assert_serialization(AuditLogEntityAction::FeaturedItemCreate, "featured_item_create"); + assert_serialization(AuditLogEntityAction::FeaturedItemDelete, "featured_item_delete"); } } @@ -78,6 +92,8 @@ mod tests { assert_eq!(AuditLogEntityAction::Edit.to_str(), "edit"); assert_eq!(AuditLogEntityAction::EditFeatures.to_str(), "edit_features"); assert_eq!(AuditLogEntityAction::Delete.to_str(), "delete"); + assert_eq!(AuditLogEntityAction::FeaturedItemCreate.to_str(), "featured_item_create"); + assert_eq!(AuditLogEntityAction::FeaturedItemDelete.to_str(), "featured_item_delete"); } #[test] @@ -86,6 +102,8 @@ mod tests { assert_eq!(AuditLogEntityAction::from_str("edit").unwrap(), AuditLogEntityAction::Edit); assert_eq!(AuditLogEntityAction::from_str("edit_features").unwrap(), AuditLogEntityAction::EditFeatures); assert_eq!(AuditLogEntityAction::from_str("delete").unwrap(), AuditLogEntityAction::Delete); + assert_eq!(AuditLogEntityAction::from_str("featured_item_create").unwrap(), AuditLogEntityAction::FeaturedItemCreate); + assert_eq!(AuditLogEntityAction::from_str("featured_item_delete").unwrap(), AuditLogEntityAction::FeaturedItemDelete); assert!(AuditLogEntityAction::from_str("foo").is_err()); } } diff --git a/crates/_types/enums/src/by_table/featured_items/featured_item_entity_type.rs b/crates/_types/enums/src/by_table/featured_items/featured_item_entity_type.rs new file mode 100644 index 0000000000..3fe7dfb00f --- /dev/null +++ b/crates/_types/enums/src/by_table/featured_items/featured_item_entity_type.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeSet; +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +/// Used in the `user_bookmarks` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] +pub enum FeaturedItemEntityType { + /// MediaFile + #[serde(rename = "media_file")] + MediaFile, + + /// ModelWeight (the new way to store models) + #[serde(rename = "model_weight")] + ModelWeight, + + /// User + #[serde(rename = "user")] + User, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(FeaturedItemEntityType); +impl_mysql_enum_coders!(FeaturedItemEntityType); +impl_mysql_from_row!(FeaturedItemEntityType); + +/// NB: Legacy API for older code. +impl FeaturedItemEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::MediaFile => "media_file", + Self::ModelWeight => "model_weight", + Self::User => "user", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "media_file" => Ok(Self::MediaFile), + "model_weight" => Ok(Self::ModelWeight), + "user" => Ok(Self::User), + _ => Err(format!("invalid value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + // NB: BTreeSet is sorted + // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. + BTreeSet::from([ + Self::MediaFile, + Self::ModelWeight, + Self::User, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(FeaturedItemEntityType::MediaFile, "media_file"); + assert_serialization(FeaturedItemEntityType::ModelWeight, "model_weight"); + assert_serialization(FeaturedItemEntityType::User, "user"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(FeaturedItemEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(FeaturedItemEntityType::ModelWeight.to_str(), "model_weight"); + assert_eq!(FeaturedItemEntityType::User.to_str(), "user"); + } + + #[test] + fn test_from_str() { + assert_eq!(FeaturedItemEntityType::from_str("media_file").unwrap(), FeaturedItemEntityType::MediaFile); + assert_eq!(FeaturedItemEntityType::from_str("model_weight").unwrap(), FeaturedItemEntityType::ModelWeight); + assert_eq!(FeaturedItemEntityType::from_str("user").unwrap(), FeaturedItemEntityType::User); + assert!(FeaturedItemEntityType::from_str("foo").is_err()); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(FeaturedItemEntityType::all_variants().len(), FeaturedItemEntityType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in FeaturedItemEntityType::all_variants() { + assert_eq!(variant, FeaturedItemEntityType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, FeaturedItemEntityType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, FeaturedItemEntityType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 32; + for variant in FeaturedItemEntityType::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } + } +} diff --git a/crates/_types/enums/src/by_table/featured_items/mod.rs b/crates/_types/enums/src/by_table/featured_items/mod.rs new file mode 100644 index 0000000000..71c7785bce --- /dev/null +++ b/crates/_types/enums/src/by_table/featured_items/mod.rs @@ -0,0 +1 @@ +pub mod featured_item_entity_type; \ No newline at end of file diff --git a/crates/_types/enums/src/by_table/mod.rs b/crates/_types/enums/src/by_table/mod.rs index 96b5275c14..0ea73ef4df 100644 --- a/crates/_types/enums/src/by_table/mod.rs +++ b/crates/_types/enums/src/by_table/mod.rs @@ -4,6 +4,7 @@ pub mod batch_generations; pub mod comments; pub mod email_sender_jobs; pub mod entity_stats; +pub mod featured_items; pub mod generic_download_jobs; pub mod generic_inference_jobs; pub mod generic_synthetic_ids; diff --git a/crates/lib/mysql_queries/src/queries/featured_items/delete_featured_item.rs b/crates/lib/mysql_queries/src/queries/featured_items/delete_featured_item.rs new file mode 100644 index 0000000000..781126bd5f --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/featured_items/delete_featured_item.rs @@ -0,0 +1,32 @@ +use sqlx::{Executor, MySql}; + +use composite_identifiers::by_table::featured_items::featured_item_entity::FeaturedItemEntity; +use errors::AnyhowResult; + +pub async fn delete_featured_item<'e, 'c, E>( + entity: &'e FeaturedItemEntity, + mysql_executor: E +) + -> AnyhowResult<()> + where E: 'e + Executor<'c, Database = MySql> +{ + let (entity_type, entity_token) = entity.get_composite_keys(); + + sqlx::query!( + r#" +UPDATE featured_items +SET + deleted_at = CURRENT_TIMESTAMP +WHERE + entity_type = ? + AND entity_token = ? +LIMIT 1 + "#, + entity_type, + entity_token + ) + .execute(mysql_executor) + .await?; + + Ok(()) +} diff --git a/crates/lib/mysql_queries/src/queries/featured_items/mod.rs b/crates/lib/mysql_queries/src/queries/featured_items/mod.rs new file mode 100644 index 0000000000..4ac904b606 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/featured_items/mod.rs @@ -0,0 +1,2 @@ +pub mod upsert_featured_item; +pub mod delete_featured_item; \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/featured_items/upsert_featured_item.rs b/crates/lib/mysql_queries/src/queries/featured_items/upsert_featured_item.rs new file mode 100644 index 0000000000..b983daf9ef --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/featured_items/upsert_featured_item.rs @@ -0,0 +1,51 @@ +use std::marker::PhantomData; + +use anyhow::anyhow; +use sqlx::{Executor, MySql}; + +use composite_identifiers::by_table::featured_items::featured_item_entity::FeaturedItemEntity; +use errors::AnyhowResult; + +pub struct UpsertFeaturedItemArgs<'e, 'c, E> + where E: 'e + Executor<'c, Database = MySql> +{ + pub entity: &'e FeaturedItemEntity, + + pub mysql_executor: E, + + // TODO: Not sure if this works to tell the compiler we need the lifetime annotation. + // See: https://doc.rust-lang.org/std/marker/struct.PhantomData.html#unused-lifetime-parameters + pub phantom: PhantomData<&'c E>, +} + +pub async fn upsert_featured_item<'e, 'c : 'e, E>( + args: UpsertFeaturedItemArgs<'e, 'c, E>, +) + -> AnyhowResult<()> + where E: 'e + Executor<'c, Database = MySql> +{ + + let (entity_type, entity_token) = args.entity.get_composite_keys(); + + let query_result = sqlx::query!( + r#" +INSERT INTO featured_items +SET + entity_type = ?, + entity_token = ? +ON DUPLICATE KEY UPDATE + deleted_at = NULL + "#, + entity_type, + entity_token, + ) + .execute(args.mysql_executor) + .await; + + let _record_id = match query_result { + Ok(res) => res.last_insert_id(), + Err(err) => return Err(anyhow!("Mysql error: {:?}", err)), + }; + + Ok(()) +} diff --git a/crates/lib/mysql_queries/src/queries/mod.rs b/crates/lib/mysql_queries/src/queries/mod.rs index b7699ad775..aa5e17aa0f 100644 --- a/crates/lib/mysql_queries/src/queries/mod.rs +++ b/crates/lib/mysql_queries/src/queries/mod.rs @@ -5,6 +5,7 @@ pub mod billing; pub mod comments; pub mod email_sender_jobs; pub mod entity_stats; +pub mod featured_items; pub mod generic_download; pub mod generic_inference; pub mod generic_synthetic_ids; diff --git a/crates/lib/mysql_queries/src/queries/users/user_bookmarks/user_bookmark_entity_token.rs b/crates/lib/mysql_queries/src/queries/users/user_bookmarks/user_bookmark_entity_token.rs index aee34d7deb..2b192dbf5b 100644 --- a/crates/lib/mysql_queries/src/queries/users/user_bookmarks/user_bookmark_entity_token.rs +++ b/crates/lib/mysql_queries/src/queries/users/user_bookmarks/user_bookmark_entity_token.rs @@ -9,6 +9,8 @@ use tokens::tokens::w2l_results::W2lResultToken; use tokens::tokens::w2l_templates::W2lTemplateToken; use tokens::tokens::zs_voices::ZsVoiceToken; +// TODO(bt,2024-05-05): Make this a composite_identifier enum + pub enum UserBookmarkEntityToken { User(UserToken), ModelWeight(ModelWeightToken), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs new file mode 100644 index 0000000000..29d252a640 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/create_featured_item_handler.rs @@ -0,0 +1,197 @@ +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use log::{error, warn}; +use r2d2_redis::redis::transaction; +use sqlx::Acquire; +use utoipa::ToSchema; + +use composite_identifiers::by_table::audit_logs::audit_log_entity::AuditLogEntity; +use composite_identifiers::by_table::featured_items::featured_item_entity::FeaturedItemEntity; +use enums::by_table::audit_logs::audit_log_entity_action::AuditLogEntityAction; +use enums::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::audit_logs::insert_audit_log::{insert_audit_log, InsertAuditLogArgs}; +use mysql_queries::queries::audit_logs::transactional_insert_audit_log::{transactional_insert_audit_log, TransactionalInsertAuditLogArgs}; +use mysql_queries::queries::entity_stats::stats_entity_token::StatsEntityToken; +use mysql_queries::queries::entity_stats::upsert_entity_stats_on_bookmark_event::{BookmarkAction, upsert_entity_stats_on_bookmark_event, UpsertEntityStatsArgs}; +use mysql_queries::queries::featured_items::upsert_featured_item::{upsert_featured_item, UpsertFeaturedItemArgs}; +use mysql_queries::queries::users::user_bookmarks::get_total_bookmark_count_for_entity::get_total_bookmark_count_for_entity; +use mysql_queries::queries::users::user_bookmarks::get_user_bookmark_transactional_locking::{BookmarkIdentifier, get_user_bookmark_transactional_locking}; +use mysql_queries::queries::users::user_bookmarks::user_bookmark_entity_token::UserBookmarkEntityToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::tts_models::TtsModelToken; +use tokens::tokens::tts_results::TtsResultToken; +use tokens::tokens::user_bookmarks::UserBookmarkToken; +use tokens::tokens::users::UserToken; +use tokens::tokens::voice_conversion_models::VoiceConversionModelToken; +use tokens::tokens::w2l_results::W2lResultToken; +use tokens::tokens::w2l_templates::W2lTemplateToken; +use tokens::tokens::zs_voices::ZsVoiceToken; + +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::server_state::ServerState; + +#[derive(Deserialize, ToSchema)] +pub struct CreateFeaturedItemRequest { + entity_token: String, + entity_type: FeaturedItemEntityType, +} + +#[derive(Serialize, ToSchema)] +pub struct CreateFeaturedItemSuccessResponse { + pub success: bool, +} + +#[derive(Debug, ToSchema)] +pub enum CreateFeaturedItemError { + BadInput(String), + NotAuthorized, + ServerError, +} + +impl ResponseError for CreateFeaturedItemError { + fn status_code(&self) -> StatusCode { + match *self { + CreateFeaturedItemError::BadInput(_) => StatusCode::BAD_REQUEST, + CreateFeaturedItemError::NotAuthorized => StatusCode::UNAUTHORIZED, + CreateFeaturedItemError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + CreateFeaturedItemError::BadInput(reason) => reason.to_string(), + CreateFeaturedItemError::NotAuthorized => "unauthorized".to_string(), + CreateFeaturedItemError::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 CreateFeaturedItemError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +#[utoipa::path( + post, + tag = "Featured Items", + path = "/v1/featured_item/create", + request_body = CreateFeaturedItemRequest, + responses( + (status = 200, body = CreateFeaturedItemSuccessResponse), + (status = 400, body = CreateFeaturedItemError), + ) +)] +pub async fn create_featured_item_handler( + http_request: HttpRequest, + request: web::Json, + server_state: web::Data>, +) -> Result +{ + // NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from + // querying the database for entity existence, this is the next best way to prevent incorrect comment + // attachment. This is a bit of a bad process, though, since the token types are supposed to be opaque. + let token = request.entity_token.as_str(); + let token_prefix_matches = match request.entity_type { + // NB: Users had an older prefix (U:) that got replaced with the new prefix (user_) + FeaturedItemEntityType::User => token.starts_with(UserToken::token_prefix()) || token.starts_with("U:"), + FeaturedItemEntityType::MediaFile => token.starts_with(MediaFileToken::token_prefix()), + FeaturedItemEntityType::ModelWeight => token.starts_with(ModelWeightToken::token_prefix()), + }; + + if !token_prefix_matches { + warn!("invalid token prefix: {:?} for {:?}", request.entity_token, request.entity_type); + return Err(CreateFeaturedItemError::BadInput("invalid token prefix".to_string())); + } + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + CreateFeaturedItemError::ServerError + })?; + + 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); + CreateFeaturedItemError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(CreateFeaturedItemError::NotAuthorized); + } + }; + + let is_mod = user_session.can_ban_users; + + if !is_mod { + warn!("not moderator"); + return Err(CreateFeaturedItemError::NotAuthorized); + } + + let entity = FeaturedItemEntity::from_entity_type_and_token( + request.entity_type, &request.entity_token); + + let mut transaction = mysql_connection.begin().await + .map_err(|err| { + error!("error creating transaction: {:?}", err); + CreateFeaturedItemError::ServerError + })?; + + let upsert_result = upsert_featured_item(UpsertFeaturedItemArgs { + entity: &entity, + mysql_executor: &mut *transaction, + phantom: Default::default(), + }).await; + + if let Err(err) = upsert_result { + warn!("error upserting user_bookmark: {:?}", err); + return Err(CreateFeaturedItemError::ServerError); + } + + let ip_address = get_request_ip(&http_request); + + // NB: fail open + let _r = transactional_insert_audit_log(TransactionalInsertAuditLogArgs { + entity: &AuditLogEntity::User(user_session.user_token.clone()), + entity_action: AuditLogEntityAction::FeaturedItemCreate, + maybe_actor_user_token: Some(&user_session.user_token), + maybe_actor_anonymous_visitor_token: None, + actor_ip_address: &ip_address, + is_actor_moderator: true, + transaction: &mut transaction, + }).await; + + transaction.commit().await + .map_err(|err| { + error!("error committing transaction: {:?}", err); + CreateFeaturedItemError::ServerError + })?; + + let response = CreateFeaturedItemSuccessResponse { + success: true, + }; + + let body = serde_json::to_string(&response) + .map_err(|_e| CreateFeaturedItemError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs new file mode 100644 index 0000000000..f542ad5419 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/delete_featured_item_handler.rs @@ -0,0 +1,186 @@ +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Path; +use log::{error, info, warn}; +use sqlx::Acquire; +use utoipa::ToSchema; + +use composite_identifiers::by_table::audit_logs::audit_log_entity::AuditLogEntity; +use composite_identifiers::by_table::featured_items::featured_item_entity::FeaturedItemEntity; +use enums::by_table::audit_logs::audit_log_entity_action::AuditLogEntityAction; +use enums::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::audit_logs::insert_audit_log::{insert_audit_log, InsertAuditLogArgs}; +use mysql_queries::queries::audit_logs::transactional_insert_audit_log::{transactional_insert_audit_log, TransactionalInsertAuditLogArgs}; +use mysql_queries::queries::entity_stats::stats_entity_token::StatsEntityToken; +use mysql_queries::queries::entity_stats::upsert_entity_stats_on_bookmark_event::{BookmarkAction, upsert_entity_stats_on_bookmark_event, UpsertEntityStatsArgs}; +use mysql_queries::queries::featured_items::delete_featured_item::delete_featured_item; +use mysql_queries::queries::users::user_bookmarks::delete_user_bookmark::delete_user_bookmark; +use mysql_queries::queries::users::user_bookmarks::get_user_bookmark_transactional_locking::{BookmarkIdentifier, get_user_bookmark_transactional_locking}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::user_bookmarks::UserBookmarkToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::endpoints::featured_items::create_featured_item_handler::CreateFeaturedItemError; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::response_success_helpers::simple_json_success; +use crate::server_state::ServerState; + +#[derive(Deserialize, ToSchema)] +pub struct DeleteFeaturedItemRequest { + entity_token: String, + entity_type: FeaturedItemEntityType, +} + +#[derive(Debug, ToSchema)] +pub enum DeleteFeaturedItemError { + BadInput(String), + NotAuthorized, + NotFound, + ServerError, +} + +impl ResponseError for DeleteFeaturedItemError { + fn status_code(&self) -> StatusCode { + match *self { + DeleteFeaturedItemError::BadInput(_) => StatusCode::BAD_REQUEST, + DeleteFeaturedItemError::NotAuthorized => StatusCode::UNAUTHORIZED, + DeleteFeaturedItemError::NotFound => StatusCode::NOT_FOUND, + DeleteFeaturedItemError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + DeleteFeaturedItemError::BadInput(reason) => reason.to_string(), + DeleteFeaturedItemError::NotAuthorized => "unauthorized".to_string(), + DeleteFeaturedItemError::NotFound => "not found".to_string(), + DeleteFeaturedItemError::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 DeleteFeaturedItemError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +#[utoipa::path( + delete, + tag = "Featured Items", + path = "/v1/featured_item/delete", + params( + ("user_bookmark_token", description = "UserBookmarkToken"), + ), + responses( + (status = 200, description = "Delete User Bookmark", body = SimpleGenericJsonSuccess), + (status = 400, description = "Bad input", body = DeleteFeaturedItemError), + (status = 401, description = "Not authorized", body = DeleteFeaturedItemError), + (status = 404, description = "Not found", body = DeleteFeaturedItemError), + (status = 500, description = "Server error", body = DeleteFeaturedItemError), + ), +)] +pub async fn delete_user_bookmark_handler( + http_request: HttpRequest, + request: web::Json, + server_state: web::Data> +) -> Result { + + // NB(bt,2023-12-14): Kasisnu found that we're getting entity type mismatches in production. Apart from + // querying the database for entity existence, this is the next best way to prevent incorrect comment + // attachment. This is a bit of a bad process, though, since the token types are supposed to be opaque. + let token = request.entity_token.as_str(); + let token_prefix_matches = match request.entity_type { + // NB: Users had an older prefix (U:) that got replaced with the new prefix (user_) + FeaturedItemEntityType::User => token.starts_with(UserToken::token_prefix()) || token.starts_with("U:"), + FeaturedItemEntityType::MediaFile => token.starts_with(MediaFileToken::token_prefix()), + FeaturedItemEntityType::ModelWeight => token.starts_with(ModelWeightToken::token_prefix()), + }; + + if !token_prefix_matches { + warn!("invalid token prefix: {:?} for {:?}", request.entity_token, request.entity_type); + return Err(DeleteFeaturedItemError::BadInput("invalid token prefix".to_string())); + } + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + DeleteFeaturedItemError::ServerError + })?; + + 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); + DeleteFeaturedItemError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + return Err(DeleteFeaturedItemError::NotAuthorized); + } + }; + + let is_mod = user_session.can_ban_users; + + if !is_mod { + warn!("not moderator"); + return Err(DeleteFeaturedItemError::NotAuthorized); + } + + let entity = FeaturedItemEntity::from_entity_type_and_token( + request.entity_type, &request.entity_token); + + let mut transaction = mysql_connection.begin().await + .map_err(|err| { + error!("error creating transaction: {:?}", err); + DeleteFeaturedItemError::ServerError + })?; + + let delete_result = delete_featured_item( + &entity, + &mut *transaction, + ).await; + + let ip_address = get_request_ip(&http_request); + + // NB: fail open + let _r = transactional_insert_audit_log(TransactionalInsertAuditLogArgs { + entity: &AuditLogEntity::User(user_session.user_token.clone()), + entity_action: AuditLogEntityAction::FeaturedItemDelete, + maybe_actor_user_token: Some(&user_session.user_token), + maybe_actor_anonymous_visitor_token: None, + actor_ip_address: &ip_address, + is_actor_moderator: true, + transaction: &mut transaction, + }).await; + + transaction.commit().await + .map_err(|err| { + error!("error committing transaction: {:?}", err); + DeleteFeaturedItemError::ServerError + })?; + + match delete_result { + Ok(_) => {}, + Err(_err) => { + return Err(DeleteFeaturedItemError::ServerError); + } + }; + + Ok(simple_json_success()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/mod.rs new file mode 100644 index 0000000000..ea00c5e7f5 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/featured_items/mod.rs @@ -0,0 +1,2 @@ +pub mod create_featured_item_handler; +pub mod delete_featured_item_handler; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index 4daa5ce14f..b33bc8413a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -6,6 +6,7 @@ pub mod conversion; pub mod download_job; pub mod engine; pub mod events; +pub mod featured_items; pub mod flags; pub mod image_gen; pub mod inference_job; @@ -30,6 +31,6 @@ pub mod vocoders; pub mod voice_clone_requests; pub mod voice_conversion; pub mod voice_designer; +pub mod w2l; pub mod weights; pub mod workflows; -pub mod w2l;