diff --git a/Cargo.lock b/Cargo.lock index e4cbf7b3e8..afec32da8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7745,6 +7745,7 @@ dependencies = [ "log", "md-5", "once_cell", + "r2d2_redis", "regex", "reusable_types", "serde", diff --git a/crates/lib/database_queries/src/queries/users/user_badges/list_user_badges.rs b/crates/lib/database_queries/src/queries/users/user_badges/list_user_badges.rs index d17d4dff7e..0be9496d3d 100644 --- a/crates/lib/database_queries/src/queries/users/user_badges/list_user_badges.rs +++ b/crates/lib/database_queries/src/queries/users/user_badges/list_user_badges.rs @@ -5,7 +5,7 @@ use log::{warn, info}; use sqlx::{MySql, MySqlPool}; use sqlx::pool::PoolConnection; -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct UserBadgeForList { pub slug: String, pub title: String, diff --git a/crates/lib/database_queries/src/queries/users/user_profiles/get_user_profile_by_username.rs b/crates/lib/database_queries/src/queries/users/user_profiles/get_user_profile_by_username.rs index d71cf3c336..9d7c976594 100644 --- a/crates/lib/database_queries/src/queries/users/user_profiles/get_user_profile_by_username.rs +++ b/crates/lib/database_queries/src/queries/users/user_profiles/get_user_profile_by_username.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use tokens::users::user::UserToken; // TODO: Make non-`Serialize` and make the HTTP endpoints do the work -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct UserProfileResult { pub user_token: UserToken, pub username: String, @@ -49,7 +49,7 @@ pub struct UserProfileResult { } // TODO: Make non-`Serialize` and make the HTTP endpoints do the work -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct UserProfileModeratorFields { pub is_banned: bool, pub maybe_mod_comments: Option, diff --git a/crates/service/plugins/users/Cargo.toml b/crates/service/plugins/users/Cargo.toml index c8ce066ac6..299063e3f7 100644 --- a/crates/service/plugins/users/Cargo.toml +++ b/crates/service/plugins/users/Cargo.toml @@ -55,3 +55,7 @@ toml = "0.5.8" # "rustls" doesn't appear to work with IP addresses, per: # https://github.com/launchbadge/sqlx/issues/1095#issuecomment-798050828 sqlx = { version = "0.6.2", features = [ "mysql", "runtime-actix-native-tls", "offline", "chrono" ] } + +# TODO: Shouldn't really be a dependency, but need to add caching for the DDoS attack. +# Redis Database +r2d2_redis = "0.14.0" diff --git a/crates/service/plugins/users/src/endpoints/get_profile_handler.rs b/crates/service/plugins/users/src/endpoints/get_profile_handler.rs index 5090bf1d7f..51a7587002 100644 --- a/crates/service/plugins/users/src/endpoints/get_profile_handler.rs +++ b/crates/service/plugins/users/src/endpoints/get_profile_handler.rs @@ -11,18 +11,20 @@ use chrono::{DateTime, Utc}; use crate::utils::session_checker::SessionChecker; use database_queries::queries::users::user_badges::list_user_badges::UserBadgeForList; use database_queries::queries::users::user_badges::list_user_badges::list_user_badges; -use database_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username_from_connection; +use database_queries::queries::users::user_profiles::get_user_profile_by_username::{get_user_profile_by_username_from_connection, UserProfileResult}; use enums::common::visibility::Visibility; use http_server_common::request::get_request_header_optional::get_request_header_optional; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use http_server_common::util::timer::MultiBenchmarkingTimer; -use log::warn; +use log::{error, warn}; use sqlx::MySqlPool; use std::fmt; +use r2d2_redis::{r2d2, RedisConnectionManager}; +use r2d2_redis::r2d2::PooledConnection; +use r2d2_redis::redis::Commands; use tokens::users::user::UserToken; // TODO: This is duplicated in query_user_profile -// TODO: This handler has embedded queries. #[derive(Serialize)] pub struct UserProfileRecordForResponse { @@ -97,6 +99,7 @@ pub async fn get_profile_handler( http_request: HttpRequest, path: Path, mysql_pool: web::Data, + redis_pool: web::Data>, session_checker: web::Data, ) -> Result { @@ -130,25 +133,65 @@ pub async fn get_profile_handler( is_mod = user_session.can_ban_users; } - let (pool_connection, maybe_user_profile) = - benchmark.time_async_section_moving_args("profile query", pool_connection, |mut pc| async { - let ret = get_user_profile_by_username_from_connection(&path.username, &mut pc).await; - (pc, ret) - }).await; - let user_profile = match maybe_user_profile { - Ok(Some(user_profile)) => user_profile, - Ok(None) => { - warn!("Invalid user"); - return Err(ProfileError::NotFound); - }, - Err(err) => { - warn!("User profile query error: {:?}", err); - return Err(ProfileError::ServerError); - } + // TODO: Standard cache key + let cache_key = format!("cache:userProfile:{}", path.username); + let mut maybe_redis = redis_pool.get().ok(); + + let mut maybe_user_data = None; + let mut store_in_cache = false; + + if let Some(redis) = maybe_redis.as_mut() { + maybe_user_data = try_get_user_from_redis_cache(&cache_key, redis); + } + + if maybe_user_data.is_some() { + warn!("User pulled from Redis cache key: {}", cache_key); + } + + if maybe_user_data.is_none() { + let (pool_connection, maybe_user_profile) = + benchmark.time_async_section_moving_args("profile query", pool_connection, |mut pc| async { + let ret = get_user_profile_by_username_from_connection(&path.username, &mut pc).await; + (pc, ret) + }).await; + + let user_profile = match maybe_user_profile { + Ok(Some(user_profile)) => user_profile, + Ok(None) => return Err(ProfileError::NotFound), + Err(err) => { + error!("User profile query error: {:?}", err); + return Err(ProfileError::ServerError); + } + }; + + let (_pool_connection, maybe_badges) = + benchmark.time_async_section_moving_args("badges query", pool_connection, |mut pc| async { + let ret = list_user_badges(&mut pc, &user_profile.user_token.0) + .await; + (pc, ret) + }).await; + + let badges = maybe_badges + .unwrap_or_else(|err| { + warn!("Error querying badges: {:?}", err); + return Vec::new(); // NB: Fine if this fails. Not sure why it would. + }); + + maybe_user_data = Some(RedisCacheData { + user_profile: user_profile.clone(), + badges: badges.clone(), + }); + + store_in_cache = true; + } + + let user_data = match maybe_user_data { + None => return Err(ProfileError::NotFound), + Some(user_data) => user_data, }; - let is_banned = user_profile.maybe_moderator_fields + let is_banned = user_data.user_profile.maybe_moderator_fields .as_ref() .map(|mod_fields| mod_fields.is_banned) .unwrap_or(false); @@ -158,46 +201,43 @@ pub async fn get_profile_handler( return Err(ProfileError::NotFound); } - let (_pool_connection, maybe_badges) = - benchmark.time_async_section_moving_args("badges query", pool_connection, |mut pc| async { - let ret = list_user_badges(&mut pc, &user_profile.user_token.0) - .await; - (pc, ret) - }).await; - - let badges = maybe_badges - .unwrap_or_else(|err| { - warn!("Error querying badges: {:?}", err); - return Vec::new(); // NB: Fine if this fails. Not sure why it would. - }); + if store_in_cache { + if let Some(redis) = maybe_redis.as_mut() { + if let Ok(redis_payload) = serde_json::to_string(&user_data) { + const SECONDS : usize = 60; + // NB: Compiler can't figure out the throwaway result type + let _r : Option = redis.set_ex(&cache_key, redis_payload, SECONDS).ok(); + } + } + } let mut profile_for_response = UserProfileRecordForResponse { - user_token: user_profile.user_token, - username: user_profile.username, - display_name: user_profile.display_name, - email_gravatar_hash: user_profile.email_gravatar_hash, - profile_markdown: user_profile.profile_markdown, - profile_rendered_html: user_profile.profile_rendered_html, - user_role_slug: user_profile.user_role_slug, - disable_gravatar: user_profile.disable_gravatar, - preferred_tts_result_visibility: user_profile.preferred_tts_result_visibility, - preferred_w2l_result_visibility: user_profile.preferred_w2l_result_visibility, - discord_username: user_profile.discord_username, - twitch_username: user_profile.twitch_username, - twitter_username: user_profile.twitter_username, - patreon_username: user_profile.patreon_username, - github_username: user_profile.github_username, - cashapp_username: user_profile.cashapp_username, - website_url: user_profile.website_url, - created_at: user_profile.created_at, - maybe_moderator_fields: user_profile.maybe_moderator_fields.map(|mod_fields| { + user_token: user_data.user_profile.user_token, + username: user_data.user_profile.username, + display_name: user_data.user_profile.display_name, + email_gravatar_hash: user_data.user_profile.email_gravatar_hash, + profile_markdown: user_data.user_profile.profile_markdown, + profile_rendered_html: user_data.user_profile.profile_rendered_html, + user_role_slug: user_data.user_profile.user_role_slug, + disable_gravatar: user_data.user_profile.disable_gravatar, + preferred_tts_result_visibility: user_data.user_profile.preferred_tts_result_visibility, + preferred_w2l_result_visibility: user_data.user_profile.preferred_w2l_result_visibility, + discord_username: user_data.user_profile.discord_username, + twitch_username: user_data.user_profile.twitch_username, + twitter_username: user_data.user_profile.twitter_username, + patreon_username: user_data.user_profile.patreon_username, + github_username: user_data.user_profile.github_username, + cashapp_username: user_data.user_profile.cashapp_username, + website_url: user_data.user_profile.website_url, + created_at: user_data.user_profile.created_at, + maybe_moderator_fields: user_data.user_profile.maybe_moderator_fields.map(|mod_fields| { UserProfileModeratorFields { is_banned: mod_fields.is_banned, maybe_mod_comments: mod_fields.maybe_mod_comments, maybe_mod_user_token: mod_fields.maybe_mod_user_token, } }), - badges, + badges: user_data.badges, }; if !is_mod { @@ -231,3 +271,26 @@ pub async fn get_profile_handler( Ok(http_response) } + + +#[derive(Serialize, Deserialize, Clone)] +pub (crate) struct RedisCacheData { + pub user_profile: UserProfileResult, + pub badges: Vec, +} + +// TODO: Async +pub (crate) fn try_get_user_from_redis_cache( + cache_key: &str, + redis: &mut PooledConnection, +) -> Option { + + let results : Option = redis.get(cache_key).ok().flatten(); + + let redis_data = match results { + None => return None, + Some(redis_data) => redis_data, + }; + + serde_json::from_str(&redis_data).ok() +} diff --git a/crates/service/web/storyteller_web/src/main.rs b/crates/service/web/storyteller_web/src/main.rs index 0f67b144da..5ec5631ac3 100644 --- a/crates/service/web/storyteller_web/src/main.rs +++ b/crates/service/web/storyteller_web/src/main.rs @@ -506,6 +506,7 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()> let app = App::new() .app_data(web::Data::new(server_state_arc.firehose_publisher.clone())) .app_data(web::Data::new(server_state_arc.mysql_pool.clone())) + .app_data(web::Data::new(server_state_arc.redis_pool.clone())) .app_data(web::Data::new(server_state_arc.session_checker.clone())) .app_data(web::Data::new(server_state_arc.cookie_manager.clone())) .app_data(web::Data::new(server_state_arc.stripe.clone().config.clone()))