diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_permissions.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_permissions.rs new file mode 100644 index 0000000000..aa36ad7f10 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_permissions.rs @@ -0,0 +1,107 @@ +use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use enums::by_table::users::user_feature_flag::UserFeatureFlag; +use std::collections::BTreeSet; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema, Clone)] +pub struct AppStatePermissions { + /// Moderation flag. + /// Use this to determine if an account can act on other accounts. + pub is_moderator: bool, + + /// Collection of feature / rollout flags + /// This is the proper place to detect if a user has access to some rollout (non-paywall) feature. + /// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes + pub feature_flags: BTreeSet, + + /// Legacy feature flags. Do not use. + #[deprecated(note = "DO NOT USE. This is only to port old logic.")] + pub legacy_permission_flags: AppStateLegacyPermissionFlags, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct AppStateLegacyPermissionFlags { + // Usage permissions: + pub can_use_tts: bool, + pub can_use_w2l: bool, + pub can_delete_own_tts_results: bool, + pub can_delete_own_w2l_results: bool, + pub can_delete_own_account: bool, + + // Contribution permissions: + pub can_upload_tts_models: bool, + pub can_upload_w2l_templates: bool, + pub can_delete_own_tts_models: bool, + pub can_delete_own_w2l_templates: bool, + + // Moderation permissions: + pub can_approve_w2l_templates: bool, + pub can_edit_other_users_profiles: bool, + pub can_edit_other_users_tts_models: bool, + pub can_edit_other_users_w2l_templates: bool, + pub can_delete_other_users_tts_models: bool, + pub can_delete_other_users_tts_results: bool, + pub can_delete_other_users_w2l_templates: bool, + pub can_delete_other_users_w2l_results: bool, + pub can_ban_users: bool, + pub can_delete_users: bool, +} + +pub fn get_permissions( + maybe_user: Option<&UserSessionExtended> +) -> AppStatePermissions { + match maybe_user { + None => LOGGED_OUT_PERMISSIONS.clone(), + Some(user) => AppStatePermissions { + is_moderator: user.role.user_role_slug == "moderator", + feature_flags: user.feature_flags.clone_flags(), + legacy_permission_flags: AppStateLegacyPermissionFlags { + can_use_tts: user.role.can_use_tts, + can_use_w2l: user.role.can_use_w2l, + can_delete_own_tts_results: user.role.can_delete_own_tts_results, + can_delete_own_w2l_results: user.role.can_delete_own_w2l_results, + can_delete_own_account: user.role.can_delete_own_account, + can_upload_tts_models: user.role.can_upload_tts_models, + can_upload_w2l_templates: user.role.can_upload_w2l_templates, + can_delete_own_tts_models: user.role.can_delete_own_tts_models, + can_delete_own_w2l_templates: user.role.can_delete_own_w2l_templates, + can_approve_w2l_templates: user.role.can_approve_w2l_templates, + can_edit_other_users_profiles: user.role.can_edit_other_users_profiles, + can_edit_other_users_tts_models: user.role.can_edit_other_users_tts_models, + can_edit_other_users_w2l_templates: user.role.can_edit_other_users_w2l_templates, + can_delete_other_users_tts_models: user.role.can_delete_other_users_tts_models, + can_delete_other_users_tts_results: user.role.can_delete_other_users_tts_results, + can_delete_other_users_w2l_templates: user.role.can_delete_other_users_w2l_templates, + can_delete_other_users_w2l_results: user.role.can_delete_other_users_w2l_results, + can_ban_users: user.role.can_ban_users, + can_delete_users: user.role.can_delete_users, + }, + }, + } +} + +const LOGGED_OUT_PERMISSIONS : AppStatePermissions = AppStatePermissions { + is_moderator: false, + feature_flags: BTreeSet::new(), + legacy_permission_flags: AppStateLegacyPermissionFlags { + can_use_tts: false, + can_use_w2l: false, + can_delete_own_tts_results: false, + can_delete_own_w2l_results: false, + can_delete_own_account: false, + can_upload_tts_models: false, + can_upload_w2l_templates: false, + can_delete_own_tts_models: false, + can_delete_own_w2l_templates: false, + can_approve_w2l_templates: false, + can_edit_other_users_profiles: false, + can_edit_other_users_tts_models: false, + can_edit_other_users_w2l_templates: false, + can_delete_other_users_tts_models: false, + can_delete_other_users_tts_results: false, + can_delete_other_users_w2l_templates: false, + can_delete_other_users_w2l_results: false, + can_ban_users: false, + can_delete_users: false, + }, +}; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs index b176f20a14..27bfb502a2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_premium_info.rs @@ -1,5 +1,7 @@ use billing_component::stripe::traits::internal_user_lookup::UserMetadata; use utoipa::ToSchema; +use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord; +use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; #[derive(Serialize, ToSchema)] pub struct AppStatePremiumInfo { @@ -7,6 +9,10 @@ pub struct AppStatePremiumInfo { /// having *either* a loyalty perk or a paid subscription. pub user_has_premium: bool, + /// If the user has free premium perks. + /// This is from free loyalty program perks. + pub user_has_free_premium: bool, + /// If the user has paid for a premium subscription, this will /// be true. Loyalty perks are not considered paid. pub user_has_paid_premium: bool, @@ -29,28 +35,33 @@ pub struct AppStateSubscriptionProductKey { } pub fn get_premium_info( - user_metadata: &UserMetadata, + user_metadata: &UserSessionExtended, ) -> AppStatePremiumInfo { let maybe_loyalty_program = user_metadata + .premium .maybe_loyalty_program_key .as_deref() .map(|lp| lp.to_string()); let active_subscriptions = user_metadata - .existing_subscription_keys + .premium + .subscription_plans .iter() .map(|sub| AppStateSubscriptionProductKey { - namespace: sub.internal_subscription_namespace.to_string(), - product_slug: sub.internal_subscription_product_slug.to_string(), + // TODO: Is this correct? Should it be externally facing? + namespace: sub.subscription_namespace.to_string(), + product_slug: sub.subscription_product_slug.to_string(), }) .collect::>(); let user_has_paid_premium = !active_subscriptions.is_empty(); - let user_has_premium = user_has_paid_premium || maybe_loyalty_program.is_some(); + let user_has_free_premium = maybe_loyalty_program.is_some(); + let user_has_premium = user_has_paid_premium || user_has_free_premium; AppStatePremiumInfo { user_has_premium, + user_has_free_premium, user_has_paid_premium, maybe_loyalty_program, active_subscriptions, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_user_info.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_user_info.rs new file mode 100644 index 0000000000..fcc5d8d322 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/get_user_info.rs @@ -0,0 +1,42 @@ +use crate::http_server::common_responses::user_details_lite::UserDefaultAvatarInfo; +use crate::http_server::session::lookup::user_session_extended::UserSessionExtended; +use tokens::tokens::users::UserToken; +use utoipa::ToSchema; + +/// Information on the logged-in user's account. +/// (This is a copy of the `UserDetailsLight` struct.) +#[derive(Serialize, ToSchema)] +pub struct AppStateUserInfo { + /// The token for the user + pub user_token: UserToken, + + /// The unique username someone logs in with + /// As of 2023-08-23, this is always lowercase + pub username: String, + + /// As of 2023-08-23, this is the username with capitalization + /// (In the future, a display name can be customized by the user.) + pub display_name: String, + + /// Email hash for Gravatar + /// Always set for now since login is email/username+password. + /// In the future this will need to become an optional *OR* be filled with a bogus hash. + pub gravatar_hash: String, + + /// For users without a gravatar, we show one of our own avatars. + pub default_avatar: UserDefaultAvatarInfo, + + // In the future, we'll also support user-uploaded avatars that we store on our servers. +} + +pub fn get_user_info( + user: &UserSessionExtended +) -> AppStateUserInfo { + AppStateUserInfo { + default_avatar: UserDefaultAvatarInfo::from_username(&user.user.username), + user_token: user.user_token_typed.clone(), + username: user.user.username.to_string(), + display_name: user.user.display_name.to_string(), + gravatar_hash: user.user.email_gravatar_hash.to_string(), + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/mod.rs index e2ff26263c..c2dfdf90af 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/components/mod.rs @@ -1,4 +1,6 @@ +pub mod get_permissions; pub mod get_premium_info; pub mod get_server_info; pub mod get_status_alert; +pub mod get_user_info; pub mod get_user_locale; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/get_app_state_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/get_app_state_handler.rs index dc1fb21227..db2cd6d350 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/get_app_state_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/app_state/get_app_state_handler.rs @@ -1,6 +1,8 @@ +use crate::http_server::endpoints::app_state::components::get_permissions::{get_permissions, AppStatePermissions}; use crate::http_server::endpoints::app_state::components::get_premium_info::{get_premium_info, AppStatePremiumInfo}; use crate::http_server::endpoints::app_state::components::get_server_info::{get_server_info, AppStateServerInfo}; use crate::http_server::endpoints::app_state::components::get_status_alert::{get_status_alert, AppStateStatusAlertInfo}; +use crate::http_server::endpoints::app_state::components::get_user_info::{get_user_info, AppStateUserInfo}; use crate::http_server::endpoints::app_state::components::get_user_locale::{get_user_locale, AppStateUserLocale}; use crate::state::server_state::ServerState; use actix_web::http::StatusCode; @@ -10,6 +12,7 @@ use billing_component::stripe::traits::internal_user_lookup::InternalUserLookup; use billing_component::users::http_endpoints::list_active_user_subscriptions_handler::ListActiveUserSubscriptionsError; use enums::by_table::user_ratings::entity_type::UserRatingEntityType; use enums::by_table::user_ratings::rating_value::UserRatingValue; +use hostname::get; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use log::{error, info}; use mysql_queries::composite_keys::by_table::user_ratings::user_rating_entity::UserRatingEntity; @@ -32,11 +35,12 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(60); // - server_info (once) // - detect_locale (once) // - status_alert_check (60 seconds) -// TODO: // - active_subscriptions +// TODO: // - session #[derive(Serialize, ToSchema)] pub struct AppStateResponse { + /// All endpoints return `success = true` on 200. pub success: bool, /// Tell the frontend client how fast to refresh their view of this state. @@ -57,35 +61,43 @@ pub struct AppStateResponse { pub maybe_alert: Option, /// Information on user locale (language codes, etc.) - pub user_locale: AppStateUserLocale, + pub locale: AppStateUserLocale, + + /// Whether the user is logged in. + pub logged_in: bool, /// If the user is logged into an account with a valid session, this will /// contain the user's account info. - pub maybe_user: Option, + pub maybe_user_info: Option, + + /// Information on user permissions. + pub permissions: AppStatePermissions, + + /// Contains details oof the user's premium subscription status. + pub maybe_premium: Option, } -/// User account information. -/// This is only for valid logged-in users. -#[derive(Serialize, ToSchema)] -pub struct AppStateUserAccountInfo { - /// Details on the user's premium account status. - pub premium: AppStatePremiumInfo, -} +// /// User account information. +// /// This is only for valid logged-in users. +// #[derive(Serialize, ToSchema)] +// pub struct AppStateUserAccountInfo { +// /// Information on the user's account +// pub account: String, +// +// /// Details on the user's premium subscription status. +// pub premium: AppStatePremiumInfo, +// } // =============== Error Response =============== #[derive(Debug, Serialize, ToSchema)] pub enum AppStateError { - BadInput(String), - NotAuthorized, ServerError, } impl ResponseError for AppStateError { fn status_code(&self) -> StatusCode { match *self { - AppStateError::BadInput(_) => StatusCode::BAD_REQUEST, - AppStateError::NotAuthorized => StatusCode::UNAUTHORIZED, AppStateError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -113,33 +125,25 @@ impl std::fmt::Display for AppStateError { /// This single endpoint can replace the following endpoints: /// - `GET /detect_locale` /// - `GET /server_info` +/// - `GET /v1/billing/active_subscriptions` (TODO: port new logic to old endpoint) /// - `GET /v1/status_alert_check` -/// -/// TODO: - `GET /v1/billing/active_subscriptions` +/// - `GET /v1/session` /// /// This endpoint will probably grow new functionality in the future as well. #[utoipa::path( get, tag = "App State", path = "/v1/app_state", - params( - ("entity_type", description = "The type of the entity being rated."), - ("entity_token", description = "Entity token"), - ), responses( - (status = 200, description = "List User Bookmarks", body = AppStateResponse), - (status = 400, description = "Bad input", body = AppStateError), - (status = 401, description = "Not authorized", body = AppStateError), + (status = 200, description = "Success response", body = AppStateResponse), (status = 500, description = "Server error", body = AppStateError), ), )] pub async fn get_app_state_handler( http_request: HttpRequest, - internal_user_lookup: web::Data, server_state: web::Data> ) -> Result, AppStateError> { - /* let mut mysql_connection = server_state.mysql_pool.acquire() .await .map_err(|e| { @@ -149,48 +153,36 @@ pub async fn get_app_state_handler( let maybe_user_session = server_state .session_checker - .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .maybe_get_user_session_extended_from_connection(&http_request, &mut mysql_connection) .await .map_err(|e| { error!("Session checker error: {:?}", e); AppStateError::ServerError })?; - let user_session = match maybe_user_session { - Some(session) => session, - None => { - info!("not logged in"); - return Err(AppStateError::NotAuthorized); - } - }; - */ - - let maybe_user_metadata = internal_user_lookup - .lookup_user_from_http_request(&http_request) - .await - .map_err(|err| { - error!("Error looking up user: {:?}", err); - AppStateError::ServerError // NB: This was probably *our* fault. - })?; - let server_info = get_server_info(&server_state); - let user_locale = get_user_locale(&http_request); let maybe_alert = get_status_alert(&server_state); + let locale = get_user_locale(&http_request); - let mut maybe_user = None; + let mut maybe_user_info = maybe_user_session + .as_ref() + .map(|session| get_user_info(session)); - if let Some(user_metadata) = maybe_user_metadata { - maybe_user = Some(AppStateUserAccountInfo { - premium: get_premium_info(&user_metadata), - }); - } + let permissions = get_permissions(maybe_user_session.as_ref()); + + let maybe_premium = maybe_user_session + .as_ref() + .map(|session| get_premium_info(session)); Ok(Json(AppStateResponse { success: true, refresh_interval_millis: REFRESH_INTERVAL.as_millis(), server_info, - user_locale, + locale, maybe_alert, - maybe_user, + maybe_user_info, + permissions, + maybe_premium, + logged_in: false, })) }