diff --git a/Cargo.lock b/Cargo.lock index 4118ea29c2..9b0263f99d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,6 +455,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" name = "api_models" version = "0.1.0" dependencies = [ + "actix-multipart", "actix-web", "cards", "common_enums", diff --git a/config/config.example.toml b/config/config.example.toml index 03ebedf0d3..54a3ab3827 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -795,3 +795,6 @@ connector_list = "stripe,adyen,cybersource" # Supported connectors for network t host = "localhost" # Client Host port = 7000 # Client Port service = "dynamo" # Service name + +[theme_storage] +file_storage_backend = "file_system" # Theme storage backend to be used diff --git a/config/deployments/env_specific.toml b/config/deployments/env_specific.toml index fa0c0484a7..5cadc66ddf 100644 --- a/config/deployments/env_specific.toml +++ b/config/deployments/env_specific.toml @@ -327,3 +327,10 @@ check_token_status_url= "" # base url to check token status from token servic host = "localhost" # Client Host port = 7000 # Client Port service = "dynamo" # Service name + +[theme_storage] +file_storage_backend = "aws_s3" # Theme storage backend to be used + +[theme_storage.aws_s3] +region = "bucket_region" # AWS region where the S3 bucket for theme storage is located +bucket_name = "bucket" # AWS S3 bucket name for theme storage diff --git a/config/development.toml b/config/development.toml index d8251cfce7..eef44648c6 100644 --- a/config/development.toml +++ b/config/development.toml @@ -793,3 +793,6 @@ connector_list = "cybersource" [grpc_client.dynamic_routing_client] host = "localhost" port = 7000 + +[theme_storage] +file_storage_backend = "file_system" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 976a2fa2a4..f38da4183b 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -691,3 +691,6 @@ prod_intent_recipient_email = "business@example.com" # Recipient email for prod [email.aws_ses] email_role_arn = "" # The amazon resource name ( arn ) of the role which has permission to send emails sts_role_session_name = "" # An identifier for the assumed role session, used to uniquely identify a session. + +[theme_storage] +file_storage_backend = "file_system" # Theme storage backend to be used diff --git a/crates/api_models/Cargo.toml b/crates/api_models/Cargo.toml index a5d702e26f..eb706dc913 100644 --- a/crates/api_models/Cargo.toml +++ b/crates/api_models/Cargo.toml @@ -8,7 +8,7 @@ readme = "README.md" license.workspace = true [features] -errors = ["dep:actix-web", "dep:reqwest"] +errors = ["dep:reqwest"] dummy_connector = ["euclid/dummy_connector", "common_enums/dummy_connector"] detailed_errors = [] payouts = ["common_enums/payouts"] @@ -23,7 +23,8 @@ payment_methods_v2 = ["common_utils/payment_methods_v2"] dynamic_routing = [] [dependencies] -actix-web = { version = "4.5.1", optional = true } +actix-multipart = "0.6.1" +actix-web = "4.5.1" error-stack = "0.4.1" indexmap = "2.3.0" mime = "0.3.17" diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index baac14e8af..15146e304a 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -6,6 +6,7 @@ use crate::user::{ dashboard_metadata::{ GetMetaDataRequest, GetMetaDataResponse, GetMultipleMetaDataPayload, SetMetaDataRequest, }, + theme::{CreateThemeRequest, GetThemeResponse, UpdateThemeRequest, UploadFileRequest}, AcceptInviteFromEmailRequest, AuthSelectRequest, AuthorizeResponse, BeginTotpResponse, ChangePasswordRequest, ConnectAccountRequest, CreateInternalUserRequest, CreateUserAuthenticationMethodRequest, ForgotPasswordRequest, GetSsoAuthUrlRequest, @@ -61,7 +62,11 @@ common_utils::impl_api_event_type!( UpdateUserAuthenticationMethodRequest, GetSsoAuthUrlRequest, SsoSignInRequest, - AuthSelectRequest + AuthSelectRequest, + GetThemeResponse, + UploadFileRequest, + CreateThemeRequest, + UpdateThemeRequest ) ); diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index 9c70ea895a..82291ddc92 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -8,6 +8,7 @@ use crate::user_role::UserStatus; pub mod dashboard_metadata; #[cfg(feature = "dummy_connector")] pub mod sample_data; +pub mod theme; #[derive(serde::Deserialize, Debug, Clone, serde::Serialize)] pub struct SignUpWithMerchantIdRequest { diff --git a/crates/api_models/src/user/theme.rs b/crates/api_models/src/user/theme.rs new file mode 100644 index 0000000000..23218c4a16 --- /dev/null +++ b/crates/api_models/src/user/theme.rs @@ -0,0 +1,125 @@ +use actix_multipart::form::{bytes::Bytes, text::Text, MultipartForm}; +use common_enums::EntityType; +use common_utils::{id_type, types::theme::ThemeLineage}; +use masking::Secret; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct GetThemeResponse { + pub theme_id: String, + pub theme_name: String, + pub entity_type: EntityType, + pub tenant_id: id_type::TenantId, + pub org_id: Option, + pub merchant_id: Option, + pub profile_id: Option, + pub theme_data: ThemeData, +} + +#[derive(Debug, MultipartForm)] +pub struct UploadFileAssetData { + pub asset_name: Text, + #[multipart(limit = "10MB")] + pub asset_data: Bytes, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UploadFileRequest { + pub lineage: ThemeLineage, + pub asset_name: String, + pub asset_data: Secret>, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateThemeRequest { + pub lineage: ThemeLineage, + pub theme_name: String, + pub theme_data: ThemeData, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateThemeRequest { + pub lineage: ThemeLineage, + pub theme_data: ThemeData, +} + +// All the below structs are for the theme.json file, +// which will be used by frontend to style the dashboard. +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ThemeData { + settings: Settings, + urls: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Settings { + colors: Colors, + typography: Option, + buttons: Buttons, + borders: Option, + spacing: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Colors { + primary: String, + sidebar: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Typography { + font_family: Option, + font_size: Option, + heading_font_size: Option, + text_color: Option, + link_color: Option, + link_hover_color: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Buttons { + primary: PrimaryButton, + secondary: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct PrimaryButton { + background_color: Option, + text_color: Option, + hover_background_color: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct SecondaryButton { + background_color: Option, + text_color: Option, + hover_background_color: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Borders { + default_radius: Option, + border_color: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Spacing { + padding: Option, + margin: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Urls { + favicon_url: Option, + logo_url: Option, +} diff --git a/crates/common_utils/src/types/theme.rs b/crates/common_utils/src/types/theme.rs index 9ad9206acc..239cffc40d 100644 --- a/crates/common_utils/src/types/theme.rs +++ b/crates/common_utils/src/types/theme.rs @@ -1,8 +1,14 @@ -use crate::id_type; +use common_enums::EntityType; + +use crate::{ + events::{ApiEventMetric, ApiEventsType}, + id_type, impl_api_event_type, +}; /// Enum for having all the required lineage for every level. /// Currently being used for theme related APIs and queries. -#[derive(Debug)] +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(tag = "entity_type", rename_all = "snake_case")] pub enum ThemeLineage { // TODO: Add back Tenant variant when we introduce Tenant Variant in EntityType // /// Tenant lineage variant @@ -38,3 +44,52 @@ pub enum ThemeLineage { profile_id: id_type::ProfileId, }, } + +impl_api_event_type!(Miscellaneous, (ThemeLineage)); + +impl ThemeLineage { + /// Get the entity_type from the lineage + pub fn entity_type(&self) -> EntityType { + match self { + Self::Organization { .. } => EntityType::Organization, + Self::Merchant { .. } => EntityType::Merchant, + Self::Profile { .. } => EntityType::Profile, + } + } + + /// Get the tenant_id from the lineage + pub fn tenant_id(&self) -> &id_type::TenantId { + match self { + Self::Organization { tenant_id, .. } + | Self::Merchant { tenant_id, .. } + | Self::Profile { tenant_id, .. } => tenant_id, + } + } + + /// Get the org_id from the lineage + pub fn org_id(&self) -> Option<&id_type::OrganizationId> { + match self { + Self::Organization { org_id, .. } + | Self::Merchant { org_id, .. } + | Self::Profile { org_id, .. } => Some(org_id), + } + } + + /// Get the merchant_id from the lineage + pub fn merchant_id(&self) -> Option<&id_type::MerchantId> { + match self { + Self::Organization { .. } => None, + Self::Merchant { merchant_id, .. } | Self::Profile { merchant_id, .. } => { + Some(merchant_id) + } + } + } + + /// Get the profile_id from the lineage + pub fn profile_id(&self) -> Option<&id_type::ProfileId> { + match self { + Self::Organization { .. } | Self::Merchant { .. } => None, + Self::Profile { profile_id, .. } => Some(profile_id), + } + } +} diff --git a/crates/diesel_models/src/query/user/theme.rs b/crates/diesel_models/src/query/user/theme.rs index 78fd5025ef..a26e401ffb 100644 --- a/crates/diesel_models/src/query/user/theme.rs +++ b/crates/diesel_models/src/query/user/theme.rs @@ -69,6 +69,14 @@ impl Theme { } } + pub async fn find_by_theme_id(conn: &PgPooledConn, theme_id: String) -> StorageResult { + generics::generic_find_one::<::Table, _, _>( + conn, + dsl::theme_id.eq(theme_id), + ) + .await + } + pub async fn find_by_lineage( conn: &PgPooledConn, lineage: ThemeLineage, diff --git a/crates/diesel_models/src/user/theme.rs b/crates/diesel_models/src/user/theme.rs index 9841e21443..e43a182126 100644 --- a/crates/diesel_models/src/user/theme.rs +++ b/crates/diesel_models/src/user/theme.rs @@ -1,5 +1,5 @@ use common_enums::EntityType; -use common_utils::id_type; +use common_utils::{date_time, id_type, types::theme::ThemeLineage}; use diesel::{Identifiable, Insertable, Queryable, Selectable}; use time::PrimitiveDateTime; @@ -32,3 +32,21 @@ pub struct ThemeNew { pub entity_type: EntityType, pub theme_name: String, } + +impl ThemeNew { + pub fn new(theme_id: String, theme_name: String, lineage: ThemeLineage) -> Self { + let now = date_time::now(); + + Self { + theme_id, + theme_name, + tenant_id: lineage.tenant_id().to_owned(), + org_id: lineage.org_id().cloned(), + merchant_id: lineage.merchant_id().cloned(), + profile_id: lineage.profile_id().cloned(), + entity_type: lineage.entity_type(), + created_at: now, + last_modified_at: now, + } + } +} diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs index dbdba189ed..3ab56266b5 100644 --- a/crates/router/src/configs/secrets_transformers.rs +++ b/crates/router/src/configs/secrets_transformers.rs @@ -545,5 +545,6 @@ pub(crate) async fn fetch_raw_secrets( .network_tokenization_supported_card_networks, network_tokenization_service, network_tokenization_supported_connectors: conf.network_tokenization_supported_connectors, + theme_storage: conf.theme_storage, } } diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 4e559a261b..1da4a33f5f 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -128,6 +128,7 @@ pub struct Settings { pub network_tokenization_supported_card_networks: NetworkTokenizationSupportedCardNetworks, pub network_tokenization_service: Option>, pub network_tokenization_supported_connectors: NetworkTokenizationSupportedConnectors, + pub theme_storage: FileStorageConfig, } #[derive(Debug, Deserialize, Clone, Default)] @@ -886,6 +887,10 @@ impl Settings { .validate() .map_err(|err| ApplicationError::InvalidConfigurationValueError(err.into()))?; + self.theme_storage + .validate() + .map_err(|err| ApplicationError::InvalidConfigurationValueError(err.to_string()))?; + Ok(()) } } diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index 5c7b77246e..33477df633 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -96,6 +96,16 @@ pub enum UserErrors { MaxRecoveryCodeAttemptsReached, #[error("Forbidden tenant id")] ForbiddenTenantId, + #[error("Error Uploading file to Theme Storage")] + ErrorUploadingFile, + #[error("Error Retrieving file from Theme Storage")] + ErrorRetrievingFile, + #[error("Theme not found")] + ThemeNotFound, + #[error("Theme with lineage already exists")] + ThemeAlreadyExists, + #[error("Invalid field: {0} in lineage")] + InvalidThemeLineage(String), } impl common_utils::errors::ErrorSwitch for UserErrors { @@ -244,57 +254,93 @@ impl common_utils::errors::ErrorSwitch { AER::BadRequest(ApiError::new(sub_code, 50, self.get_error_message(), None)) } + Self::ErrorUploadingFile => AER::InternalServerError(ApiError::new( + sub_code, + 51, + self.get_error_message(), + None, + )), + Self::ErrorRetrievingFile => AER::InternalServerError(ApiError::new( + sub_code, + 52, + self.get_error_message(), + None, + )), + Self::ThemeNotFound => { + AER::NotFound(ApiError::new(sub_code, 53, self.get_error_message(), None)) + } + Self::ThemeAlreadyExists => { + AER::BadRequest(ApiError::new(sub_code, 54, self.get_error_message(), None)) + } + Self::InvalidThemeLineage(_) => { + AER::BadRequest(ApiError::new(sub_code, 55, self.get_error_message(), None)) + } } } } impl UserErrors { - pub fn get_error_message(&self) -> &str { + pub fn get_error_message(&self) -> String { match self { - Self::InternalServerError => "Something went wrong", - Self::InvalidCredentials => "Incorrect email or password", - Self::UserNotFound => "Email doesn’t exist. Register", - Self::UserExists => "An account already exists with this email", - Self::LinkInvalid => "Invalid or expired link", - Self::UnverifiedUser => "Kindly verify your account", - Self::InvalidOldPassword => "Old password incorrect. Please enter the correct password", - Self::EmailParsingError => "Invalid Email", - Self::NameParsingError => "Invalid Name", - Self::PasswordParsingError => "Invalid Password", - Self::UserAlreadyVerified => "User already verified", - Self::CompanyNameParsingError => "Invalid Company Name", - Self::MerchantAccountCreationError(error_message) => error_message, - Self::InvalidEmailError => "Invalid Email", - Self::MerchantIdNotFound => "Invalid Merchant ID", - Self::MetadataAlreadySet => "Metadata already set", - Self::DuplicateOrganizationId => "An Organization with the id already exists", - Self::InvalidRoleId => "Invalid Role ID", - Self::InvalidRoleOperation => "User Role Operation Not Supported", - Self::IpAddressParsingFailed => "Something went wrong", - Self::InvalidMetadataRequest => "Invalid Metadata Request", - Self::MerchantIdParsingError => "Invalid Merchant Id", - Self::ChangePasswordError => "Old and new password cannot be same", - Self::InvalidDeleteOperation => "Delete Operation Not Supported", - Self::MaxInvitationsError => "Maximum invite count per request exceeded", - Self::RoleNotFound => "Role Not Found", - Self::InvalidRoleOperationWithMessage(error_message) => error_message, - Self::RoleNameParsingError => "Invalid Role Name", - Self::RoleNameAlreadyExists => "Role name already exists", - Self::TotpNotSetup => "TOTP not setup", - Self::InvalidTotp => "Invalid TOTP", - Self::TotpRequired => "TOTP required", - Self::InvalidRecoveryCode => "Invalid Recovery Code", - Self::MaxTotpAttemptsReached => "Maximum attempts reached for TOTP", - Self::MaxRecoveryCodeAttemptsReached => "Maximum attempts reached for Recovery Code", - Self::TwoFactorAuthRequired => "Two factor auth required", - Self::TwoFactorAuthNotSetup => "Two factor auth not setup", - Self::TotpSecretNotFound => "TOTP secret not found", - Self::UserAuthMethodAlreadyExists => "User auth method already exists", - Self::InvalidUserAuthMethodOperation => "Invalid user auth method operation", - Self::AuthConfigParsingError => "Auth config parsing error", - Self::SSOFailed => "Invalid SSO request", - Self::JwtProfileIdMissing => "profile_id missing in JWT", - Self::ForbiddenTenantId => "Forbidden tenant id", + Self::InternalServerError => "Something went wrong".to_string(), + Self::InvalidCredentials => "Incorrect email or password".to_string(), + Self::UserNotFound => "Email doesn’t exist. Register".to_string(), + Self::UserExists => "An account already exists with this email".to_string(), + Self::LinkInvalid => "Invalid or expired link".to_string(), + Self::UnverifiedUser => "Kindly verify your account".to_string(), + Self::InvalidOldPassword => { + "Old password incorrect. Please enter the correct password".to_string() + } + Self::EmailParsingError => "Invalid Email".to_string(), + Self::NameParsingError => "Invalid Name".to_string(), + Self::PasswordParsingError => "Invalid Password".to_string(), + Self::UserAlreadyVerified => "User already verified".to_string(), + Self::CompanyNameParsingError => "Invalid Company Name".to_string(), + Self::MerchantAccountCreationError(error_message) => error_message.to_string(), + Self::InvalidEmailError => "Invalid Email".to_string(), + Self::MerchantIdNotFound => "Invalid Merchant ID".to_string(), + Self::MetadataAlreadySet => "Metadata already set".to_string(), + Self::DuplicateOrganizationId => { + "An Organization with the id already exists".to_string() + } + Self::InvalidRoleId => "Invalid Role ID".to_string(), + Self::InvalidRoleOperation => "User Role Operation Not Supported".to_string(), + Self::IpAddressParsingFailed => "Something went wrong".to_string(), + Self::InvalidMetadataRequest => "Invalid Metadata Request".to_string(), + Self::MerchantIdParsingError => "Invalid Merchant Id".to_string(), + Self::ChangePasswordError => "Old and new password cannot be same".to_string(), + Self::InvalidDeleteOperation => "Delete Operation Not Supported".to_string(), + Self::MaxInvitationsError => "Maximum invite count per request exceeded".to_string(), + Self::RoleNotFound => "Role Not Found".to_string(), + Self::InvalidRoleOperationWithMessage(error_message) => error_message.to_string(), + Self::RoleNameParsingError => "Invalid Role Name".to_string(), + Self::RoleNameAlreadyExists => "Role name already exists".to_string(), + Self::TotpNotSetup => "TOTP not setup".to_string(), + Self::InvalidTotp => "Invalid TOTP".to_string(), + Self::TotpRequired => "TOTP required".to_string(), + Self::InvalidRecoveryCode => "Invalid Recovery Code".to_string(), + Self::MaxTotpAttemptsReached => "Maximum attempts reached for TOTP".to_string(), + Self::MaxRecoveryCodeAttemptsReached => { + "Maximum attempts reached for Recovery Code".to_string() + } + Self::TwoFactorAuthRequired => "Two factor auth required".to_string(), + Self::TwoFactorAuthNotSetup => "Two factor auth not setup".to_string(), + Self::TotpSecretNotFound => "TOTP secret not found".to_string(), + Self::UserAuthMethodAlreadyExists => "User auth method already exists".to_string(), + Self::InvalidUserAuthMethodOperation => { + "Invalid user auth method operation".to_string() + } + Self::AuthConfigParsingError => "Auth config parsing error".to_string(), + Self::SSOFailed => "Invalid SSO request".to_string(), + Self::JwtProfileIdMissing => "profile_id missing in JWT".to_string(), + Self::ForbiddenTenantId => "Forbidden tenant id".to_string(), + Self::ErrorUploadingFile => "Error Uploading file to Theme Storage".to_string(), + Self::ErrorRetrievingFile => "Error Retrieving file from Theme Storage".to_string(), + Self::ThemeNotFound => "Theme not found".to_string(), + Self::ThemeAlreadyExists => "Theme with lineage already exists".to_string(), + Self::InvalidThemeLineage(field_name) => { + format!("Invalid field: {} in lineage", field_name) + } } } } diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 7ca4a127e8..4f77b21904 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -44,6 +44,7 @@ use crate::{ pub mod dashboard_metadata; #[cfg(feature = "dummy_connector")] pub mod sample_data; +pub mod theme; #[cfg(feature = "email")] pub async fn signup_with_merchant_id( diff --git a/crates/router/src/core/user/theme.rs b/crates/router/src/core/user/theme.rs new file mode 100644 index 0000000000..dbf11256a5 --- /dev/null +++ b/crates/router/src/core/user/theme.rs @@ -0,0 +1,225 @@ +use api_models::user::theme as theme_api; +use common_utils::{ + ext_traits::{ByteSliceExt, Encode}, + types::theme::ThemeLineage, +}; +use diesel_models::user::theme::ThemeNew; +use error_stack::ResultExt; +use hyperswitch_domain_models::api::ApplicationResponse; +use masking::ExposeInterface; +use rdkafka::message::ToBytes; +use uuid::Uuid; + +use crate::{ + core::errors::{StorageErrorExt, UserErrors, UserResponse}, + routes::SessionState, + utils::user::theme as theme_utils, +}; + +pub async fn get_theme_using_lineage( + state: SessionState, + lineage: ThemeLineage, +) -> UserResponse { + let theme = state + .global_store + .find_theme_by_lineage(lineage) + .await + .to_not_found_response(UserErrors::ThemeNotFound)?; + + let file = theme_utils::retrieve_file_from_theme_bucket( + &state, + &theme_utils::get_theme_file_key(&theme.theme_id), + ) + .await?; + + let parsed_data = file + .to_bytes() + .parse_struct("ThemeData") + .change_context(UserErrors::InternalServerError)?; + + Ok(ApplicationResponse::Json(theme_api::GetThemeResponse { + theme_id: theme.theme_id, + theme_name: theme.theme_name, + entity_type: theme.entity_type, + tenant_id: theme.tenant_id, + org_id: theme.org_id, + merchant_id: theme.merchant_id, + profile_id: theme.profile_id, + theme_data: parsed_data, + })) +} + +pub async fn get_theme_using_theme_id( + state: SessionState, + theme_id: String, +) -> UserResponse { + let theme = state + .global_store + .find_theme_by_theme_id(theme_id.clone()) + .await + .to_not_found_response(UserErrors::ThemeNotFound)?; + + let file = theme_utils::retrieve_file_from_theme_bucket( + &state, + &theme_utils::get_theme_file_key(&theme_id), + ) + .await?; + + let parsed_data = file + .to_bytes() + .parse_struct("ThemeData") + .change_context(UserErrors::InternalServerError)?; + + Ok(ApplicationResponse::Json(theme_api::GetThemeResponse { + theme_id: theme.theme_id, + theme_name: theme.theme_name, + entity_type: theme.entity_type, + tenant_id: theme.tenant_id, + org_id: theme.org_id, + merchant_id: theme.merchant_id, + profile_id: theme.profile_id, + theme_data: parsed_data, + })) +} + +pub async fn upload_file_to_theme_storage( + state: SessionState, + theme_id: String, + request: theme_api::UploadFileRequest, +) -> UserResponse<()> { + let db_theme = state + .global_store + .find_theme_by_lineage(request.lineage) + .await + .to_not_found_response(UserErrors::ThemeNotFound)?; + + if theme_id != db_theme.theme_id { + return Err(UserErrors::ThemeNotFound.into()); + } + + theme_utils::upload_file_to_theme_bucket( + &state, + &theme_utils::get_specific_file_key(&theme_id, &request.asset_name), + request.asset_data.expose(), + ) + .await?; + + Ok(ApplicationResponse::StatusOk) +} + +pub async fn create_theme( + state: SessionState, + request: theme_api::CreateThemeRequest, +) -> UserResponse { + theme_utils::validate_lineage(&state, &request.lineage).await?; + + let new_theme = ThemeNew::new( + Uuid::new_v4().to_string(), + request.theme_name, + request.lineage, + ); + + let db_theme = state + .global_store + .insert_theme(new_theme) + .await + .to_duplicate_response(UserErrors::ThemeAlreadyExists)?; + + theme_utils::upload_file_to_theme_bucket( + &state, + &theme_utils::get_theme_file_key(&db_theme.theme_id), + request + .theme_data + .encode_to_vec() + .change_context(UserErrors::InternalServerError)?, + ) + .await?; + + let file = theme_utils::retrieve_file_from_theme_bucket( + &state, + &theme_utils::get_theme_file_key(&db_theme.theme_id), + ) + .await?; + + let parsed_data = file + .to_bytes() + .parse_struct("ThemeData") + .change_context(UserErrors::InternalServerError)?; + + Ok(ApplicationResponse::Json(theme_api::GetThemeResponse { + theme_id: db_theme.theme_id, + entity_type: db_theme.entity_type, + tenant_id: db_theme.tenant_id, + org_id: db_theme.org_id, + merchant_id: db_theme.merchant_id, + profile_id: db_theme.profile_id, + theme_name: db_theme.theme_name, + theme_data: parsed_data, + })) +} + +pub async fn update_theme( + state: SessionState, + theme_id: String, + request: theme_api::UpdateThemeRequest, +) -> UserResponse { + let db_theme = state + .global_store + .find_theme_by_lineage(request.lineage) + .await + .to_not_found_response(UserErrors::ThemeNotFound)?; + + if theme_id != db_theme.theme_id { + return Err(UserErrors::ThemeNotFound.into()); + } + + theme_utils::upload_file_to_theme_bucket( + &state, + &theme_utils::get_theme_file_key(&db_theme.theme_id), + request + .theme_data + .encode_to_vec() + .change_context(UserErrors::InternalServerError)?, + ) + .await?; + + let file = theme_utils::retrieve_file_from_theme_bucket( + &state, + &theme_utils::get_theme_file_key(&db_theme.theme_id), + ) + .await?; + + let parsed_data = file + .to_bytes() + .parse_struct("ThemeData") + .change_context(UserErrors::InternalServerError)?; + + Ok(ApplicationResponse::Json(theme_api::GetThemeResponse { + theme_id: db_theme.theme_id, + entity_type: db_theme.entity_type, + tenant_id: db_theme.tenant_id, + org_id: db_theme.org_id, + merchant_id: db_theme.merchant_id, + profile_id: db_theme.profile_id, + theme_name: db_theme.theme_name, + theme_data: parsed_data, + })) +} + +pub async fn delete_theme( + state: SessionState, + theme_id: String, + lineage: ThemeLineage, +) -> UserResponse<()> { + state + .global_store + .delete_theme_by_lineage_and_theme_id(theme_id.clone(), lineage) + .await + .to_not_found_response(UserErrors::ThemeNotFound)?; + + // TODO (#6717): Delete theme folder from the theme storage. + // Currently there is no simple or easy way to delete a whole folder from S3. + // So, we are not deleting the theme folder from the theme storage. + + Ok(ApplicationResponse::StatusOk) +} diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index ec45d9e18d..9a284f1399 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -3723,6 +3723,13 @@ impl ThemeInterface for KafkaStore { self.diesel_store.insert_theme(theme).await } + async fn find_theme_by_theme_id( + &self, + theme_id: String, + ) -> CustomResult { + self.diesel_store.find_theme_by_theme_id(theme_id).await + } + async fn find_theme_by_lineage( &self, lineage: ThemeLineage, diff --git a/crates/router/src/db/user/theme.rs b/crates/router/src/db/user/theme.rs index f1e4f4f794..9b55a98d0a 100644 --- a/crates/router/src/db/user/theme.rs +++ b/crates/router/src/db/user/theme.rs @@ -16,6 +16,11 @@ pub trait ThemeInterface { theme: storage::ThemeNew, ) -> CustomResult; + async fn find_theme_by_theme_id( + &self, + theme_id: String, + ) -> CustomResult; + async fn find_theme_by_lineage( &self, lineage: ThemeLineage, @@ -41,6 +46,16 @@ impl ThemeInterface for Store { .map_err(|error| report!(errors::StorageError::from(error))) } + async fn find_theme_by_theme_id( + &self, + theme_id: String, + ) -> CustomResult { + let conn = connection::pg_connection_read(self).await?; + storage::Theme::find_by_theme_id(&conn, theme_id) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + async fn find_theme_by_lineage( &self, lineage: ThemeLineage, @@ -165,6 +180,24 @@ impl ThemeInterface for MockDb { Ok(theme) } + async fn find_theme_by_theme_id( + &self, + theme_id: String, + ) -> CustomResult { + let themes = self.themes.lock().await; + themes + .iter() + .find(|theme| theme.theme_id == theme_id) + .cloned() + .ok_or( + errors::StorageError::ValueNotFound(format!( + "Theme with id {} not found", + theme_id + )) + .into(), + ) + } + async fn find_theme_by_lineage( &self, lineage: ThemeLineage, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 3d1474ee81..0fca984cc5 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -110,6 +110,7 @@ pub struct SessionState { #[cfg(feature = "olap")] pub opensearch_client: Arc, pub grpc_client: Arc, + pub theme_storage_client: Arc, } impl scheduler::SchedulerSessionState for SessionState { fn get_db(&self) -> Box { @@ -208,6 +209,7 @@ pub struct AppState { pub file_storage_client: Arc, pub encryption_client: Arc, pub grpc_client: Arc, + pub theme_storage_client: Arc, } impl scheduler::SchedulerAppState for AppState { fn get_tenants(&self) -> Vec { @@ -367,6 +369,7 @@ impl AppState { let email_client = Arc::new(create_email_client(&conf).await); let file_storage_client = conf.file_storage.get_file_storage_client().await; + let theme_storage_client = conf.theme_storage.get_file_storage_client().await; let grpc_client = conf.grpc_client.get_grpc_client_interface().await; @@ -387,6 +390,7 @@ impl AppState { file_storage_client, encryption_client, grpc_client, + theme_storage_client, } }) .await @@ -472,6 +476,7 @@ impl AppState { #[cfg(feature = "olap")] opensearch_client: Arc::clone(&self.opensearch_client), grpc_client: Arc::clone(&self.grpc_client), + theme_storage_client: self.theme_storage_client.clone(), }) } } @@ -2130,6 +2135,23 @@ impl User { .route(web::delete().to(user::delete_sample_data)), ) } + + route = route.service( + web::scope("/theme") + .service( + web::resource("") + .route(web::get().to(user::theme::get_theme_using_lineage)) + .route(web::post().to(user::theme::create_theme)), + ) + .service( + web::resource("/{theme_id}") + .route(web::get().to(user::theme::get_theme_using_theme_id)) + .route(web::put().to(user::theme::update_theme)) + .route(web::post().to(user::theme::upload_file_to_theme_storage)) + .route(web::delete().to(user::theme::delete_theme)), + ), + ); + route } } diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 4d3718b967..1c7db127ff 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -259,7 +259,13 @@ impl From for ApiIdentifier { | Flow::ListMerchantsForUserInOrg | Flow::ListProfileForUserInOrgAndMerchant | Flow::ListInvitationsForUser - | Flow::AuthSelect => Self::User, + | Flow::AuthSelect + | Flow::GetThemeUsingLineage + | Flow::GetThemeUsingThemeId + | Flow::UploadFileToThemeStorage + | Flow::CreateTheme + | Flow::UpdateTheme + | Flow::DeleteTheme => Self::User, Flow::ListRolesV2 | Flow::ListInvitableRolesAtEntityLevel diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index af55f7f305..d5f31be396 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -1,3 +1,5 @@ +pub mod theme; + use actix_web::{web, HttpRequest, HttpResponse}; #[cfg(feature = "dummy_connector")] use api_models::user::sample_data::SampleDataRequest; diff --git a/crates/router/src/routes/user/theme.rs b/crates/router/src/routes/user/theme.rs new file mode 100644 index 0000000000..69a8e9a537 --- /dev/null +++ b/crates/router/src/routes/user/theme.rs @@ -0,0 +1,145 @@ +use actix_multipart::form::MultipartForm; +use actix_web::{web, HttpRequest, HttpResponse}; +use api_models::user::theme as theme_api; +use common_utils::types::theme::ThemeLineage; +use masking::Secret; +use router_env::Flow; + +use crate::{ + core::{api_locking, user::theme as theme_core}, + routes::AppState, + services::{api, authentication as auth}, +}; + +pub async fn get_theme_using_lineage( + state: web::Data, + req: HttpRequest, + query: web::Query, +) -> HttpResponse { + let flow = Flow::GetThemeUsingLineage; + let lineage = query.into_inner(); + + Box::pin(api::server_wrap( + flow, + state, + &req, + lineage, + |state, _, lineage, _| theme_core::get_theme_using_lineage(state, lineage), + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn get_theme_using_theme_id( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> HttpResponse { + let flow = Flow::GetThemeUsingThemeId; + let payload = path.into_inner(); + + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, _, payload, _| theme_core::get_theme_using_theme_id(state, payload), + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn upload_file_to_theme_storage( + state: web::Data, + req: HttpRequest, + path: web::Path, + MultipartForm(payload): MultipartForm, + query: web::Query, +) -> HttpResponse { + let flow = Flow::UploadFileToThemeStorage; + let theme_id = path.into_inner(); + let payload = theme_api::UploadFileRequest { + lineage: query.into_inner(), + asset_name: payload.asset_name.into_inner(), + asset_data: Secret::new(payload.asset_data.data.to_vec()), + }; + + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, _, payload, _| { + theme_core::upload_file_to_theme_storage(state, theme_id.clone(), payload) + }, + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn create_theme( + state: web::Data, + req: HttpRequest, + payload: web::Json, +) -> HttpResponse { + let flow = Flow::CreateTheme; + let payload = payload.into_inner(); + + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, _, payload, _| theme_core::create_theme(state, payload), + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn update_theme( + state: web::Data, + req: HttpRequest, + path: web::Path, + payload: web::Json, +) -> HttpResponse { + let flow = Flow::UpdateTheme; + let theme_id = path.into_inner(); + let payload = payload.into_inner(); + + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, _, payload, _| theme_core::update_theme(state, theme_id.clone(), payload), + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn delete_theme( + state: web::Data, + req: HttpRequest, + path: web::Path, + query: web::Query, +) -> HttpResponse { + let flow = Flow::DeleteTheme; + let theme_id = path.into_inner(); + let lineage = query.into_inner(); + + Box::pin(api::server_wrap( + flow, + state, + &req, + lineage, + |state, _, lineage, _| theme_core::delete_theme(state, theme_id.clone(), lineage), + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs index 443db741ae..281b95255c 100644 --- a/crates/router/src/utils/user.rs +++ b/crates/router/src/utils/user.rs @@ -27,6 +27,7 @@ pub mod dashboard_metadata; pub mod password; #[cfg(feature = "dummy_connector")] pub mod sample_data; +pub mod theme; pub mod two_factor_auth; impl UserFromToken { diff --git a/crates/router/src/utils/user/theme.rs b/crates/router/src/utils/user/theme.rs new file mode 100644 index 0000000000..13452380d9 --- /dev/null +++ b/crates/router/src/utils/user/theme.rs @@ -0,0 +1,158 @@ +use std::path::PathBuf; + +use common_utils::{id_type, types::theme::ThemeLineage}; +use error_stack::ResultExt; +use hyperswitch_domain_models::merchant_key_store::MerchantKeyStore; + +use crate::{ + core::errors::{StorageErrorExt, UserErrors, UserResult}, + routes::SessionState, +}; + +fn get_theme_dir_key(theme_id: &str) -> PathBuf { + ["themes", theme_id].iter().collect() +} + +pub fn get_specific_file_key(theme_id: &str, file_name: &str) -> PathBuf { + let mut path = get_theme_dir_key(theme_id); + path.push(file_name); + path +} + +pub fn get_theme_file_key(theme_id: &str) -> PathBuf { + get_specific_file_key(theme_id, "theme.json") +} + +fn path_buf_to_str(path: &PathBuf) -> UserResult<&str> { + path.to_str() + .ok_or(UserErrors::InternalServerError) + .attach_printable(format!("Failed to convert path {:#?} to string", path)) +} + +pub async fn retrieve_file_from_theme_bucket( + state: &SessionState, + path: &PathBuf, +) -> UserResult> { + state + .theme_storage_client + .retrieve_file(path_buf_to_str(path)?) + .await + .change_context(UserErrors::ErrorRetrievingFile) +} + +pub async fn upload_file_to_theme_bucket( + state: &SessionState, + path: &PathBuf, + data: Vec, +) -> UserResult<()> { + state + .theme_storage_client + .upload_file(path_buf_to_str(path)?, data) + .await + .change_context(UserErrors::ErrorUploadingFile) +} + +pub async fn validate_lineage(state: &SessionState, lineage: &ThemeLineage) -> UserResult<()> { + match lineage { + ThemeLineage::Organization { tenant_id, org_id } => { + validate_tenant(state, tenant_id)?; + validate_org(state, org_id).await?; + Ok(()) + } + ThemeLineage::Merchant { + tenant_id, + org_id, + merchant_id, + } => { + validate_tenant(state, tenant_id)?; + validate_org(state, org_id).await?; + validate_merchant(state, org_id, merchant_id).await?; + Ok(()) + } + ThemeLineage::Profile { + tenant_id, + org_id, + merchant_id, + profile_id, + } => { + validate_tenant(state, tenant_id)?; + validate_org(state, org_id).await?; + let key_store = validate_merchant_and_get_key_store(state, org_id, merchant_id).await?; + validate_profile(state, profile_id, merchant_id, &key_store).await?; + Ok(()) + } + } +} + +fn validate_tenant(state: &SessionState, tenant_id: &id_type::TenantId) -> UserResult<()> { + if &state.tenant.tenant_id != tenant_id { + return Err(UserErrors::InvalidThemeLineage("tenant_id".to_string()).into()); + } + Ok(()) +} + +async fn validate_org(state: &SessionState, org_id: &id_type::OrganizationId) -> UserResult<()> { + state + .store + .find_organization_by_org_id(org_id) + .await + .to_not_found_response(UserErrors::InvalidThemeLineage("org_id".to_string()))?; + Ok(()) +} + +async fn validate_merchant_and_get_key_store( + state: &SessionState, + org_id: &id_type::OrganizationId, + merchant_id: &id_type::MerchantId, +) -> UserResult { + let key_store = state + .store + .get_merchant_key_store_by_merchant_id( + &state.into(), + merchant_id, + &state.store.get_master_key().to_vec().into(), + ) + .await + .to_not_found_response(UserErrors::InvalidThemeLineage("merchant_id".to_string()))?; + + let merchant_account = state + .store + .find_merchant_account_by_merchant_id(&state.into(), merchant_id, &key_store) + .await + .to_not_found_response(UserErrors::InvalidThemeLineage("merchant_id".to_string()))?; + + if &merchant_account.organization_id != org_id { + return Err(UserErrors::InvalidThemeLineage("merchant_id".to_string()).into()); + } + + Ok(key_store) +} + +async fn validate_merchant( + state: &SessionState, + org_id: &id_type::OrganizationId, + merchant_id: &id_type::MerchantId, +) -> UserResult<()> { + validate_merchant_and_get_key_store(state, org_id, merchant_id) + .await + .map(|_| ()) +} + +async fn validate_profile( + state: &SessionState, + profile_id: &id_type::ProfileId, + merchant_id: &id_type::MerchantId, + key_store: &MerchantKeyStore, +) -> UserResult<()> { + state + .store + .find_business_profile_by_merchant_id_profile_id( + &state.into(), + key_store, + merchant_id, + profile_id, + ) + .await + .to_not_found_response(UserErrors::InvalidThemeLineage("profile_id".to_string()))?; + Ok(()) +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index b1488b904b..0330c43aa4 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -483,6 +483,18 @@ pub enum Flow { ListUsersInLineage, /// List invitations for user ListInvitationsForUser, + /// Get theme using lineage + GetThemeUsingLineage, + /// Get theme using theme id + GetThemeUsingThemeId, + /// Upload file to theme storage + UploadFileToThemeStorage, + /// Create theme + CreateTheme, + /// Update theme + UpdateTheme, + /// Delete theme + DeleteTheme, /// List initial webhook delivery attempts WebhookEventInitialDeliveryAttemptList, /// List delivery attempts for a webhook event