diff --git a/blastmud_game/src/db.rs b/blastmud_game/src/db.rs index b061107c..cf954dd7 100644 --- a/blastmud_game/src/db.rs +++ b/blastmud_game/src/db.rs @@ -8,8 +8,8 @@ use uuid::Uuid; use tokio_postgres::NoTls; use crate::message_handler::ListenerSession; use crate::DResult; -use crate::models::session::Session; -use crate::models::user::User; +use crate::models::{session::Session, user::User, item::Item}; + use serde_json; use futures::FutureExt; @@ -193,9 +193,43 @@ impl DBTrans { Ok(()) } + pub async fn find_by_username(self: &Self, username: &str) -> DResult> { + if let Some(details_json) = self.pg_trans()? + .query_opt("SELECT details FROM users WHERE username=$1", + &[&username.to_lowercase()]).await? { + return Ok(Some(serde_json::from_value(details_json.get("details"))?)) + } + Ok(None) + } + + pub async fn create_item(self: &Self, item: &Item) -> DResult { + Ok(self.pg_trans()?.query_one("INSERT INTO items (details) VALUES ($1) RETURNING item_id", + &[&serde_json::to_value(item)?]).await? + .get("item_id")) + } + + pub async fn create_user(self: &Self, session: &ListenerSession, user_dat: &User) -> DResult<()> { + self.pg_trans()?.execute("INSERT INTO users (\ + username, current_session, current_listener, details\ + ) VALUES ($1, $2, $3, $4)", &[&user_dat.username.to_lowercase(), + &session.session, + &session.listener, + &serde_json::to_value(user_dat)?]).await?; + Ok(()) + } + + pub async fn save_user_model(self: &Self, details: &User) + -> DResult<()> { + self.pg_trans()? + .execute("UPDATE users SET details = $1 WHERE username = $2", + &[&serde_json::to_value(details)?, + &details.username.to_lowercase()]).await?; + Ok(()) + } + pub async fn commit(mut self: Self) -> DResult<()> { let trans_opt = self.with_trans_mut(|t| std::mem::replace(t, None)); - for trans in trans_opt { + if let Some(trans) = trans_opt { trans.commit().await?; } Ok(()) diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index fd7f6077..ac68224f 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -13,6 +13,7 @@ mod help; mod quit; mod less_explicit_mode; mod register; +mod agree; pub struct VerbContext<'l> { session: &'l ListenerSession, @@ -34,6 +35,13 @@ pub trait UserVerb { pub type UResult = Result; + +impl From<&str> for CommandHandlingError { + fn from(input: &str) -> CommandHandlingError { + SystemError(Box::from(input)) + } +} + impl From> for CommandHandlingError { fn from(input: Box) -> CommandHandlingError { SystemError(input) @@ -58,14 +66,26 @@ static ALWAYS_AVAILABLE_COMMANDS: UserVerbRegistry = phf_map! { static UNREGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "less_explicit_mode" => less_explicit_mode::VERB, "register" => register::VERB, + "agree" => agree::VERB +}; + +static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { }; fn resolve_handler(ctx: &VerbContext, cmd: &str) -> Option<&'static UserVerbRef> { let mut result = ALWAYS_AVAILABLE_COMMANDS.get(cmd); - match ctx.user_dat { - None => { result = result.or_else(|| UNREGISTERED_COMMANDS.get(cmd)); } - Some(_) => {} + match &ctx.user_dat { + None => { + result = result.or_else(|| UNREGISTERED_COMMANDS.get(cmd)); + } + Some(user_dat) => { + if user_dat.terms.terms_complete { + result = result.or_else(|| REGISTERED_COMMANDS.get(cmd)); + } else if cmd == "agree" { + result = Some(&agree::VERB); + } + } } result @@ -95,17 +115,19 @@ pub async fn handle(session: &ListenerSession, msg: &str, pool: &DBPool) -> DRes "That's not a command I know. Try help\r\n" )) ).await?; + trans.commit().await?; } Some(handler) => { match handler.handle(&mut ctx, cmd, params).await { - Ok(()) => {} + Ok(()) => { + trans.commit().await?; + } Err(UserError(err_msg)) => { - trans.queue_for_session(session, Some(&(err_msg + "\r\n"))).await?; + pool.queue_for_session(session, Some(&(err_msg + "\r\n"))).await?; } Err(SystemError(e)) => Err(e)? } } } - trans.commit().await?; Ok(()) } diff --git a/blastmud_game/src/message_handler/user_commands/agree.rs b/blastmud_game/src/message_handler/user_commands/agree.rs new file mode 100644 index 00000000..670e1185 --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/agree.rs @@ -0,0 +1,96 @@ +use super::{VerbContext, UserVerb, UserVerbRef, UResult}; +use crate::models::user::{User, UserTermData}; +use async_trait::async_trait; +use ansi_macro::ansi; + +pub struct Verb; + +static REQUIRED_AGREEMENTS: [&str;4] = [ + "I acknowledge that BlastMud is for adults only, and certify that I am over 18 years of age \ + (or any higher relevant age of majority in my country) and want to view this content.", + "THIS GAME IS PROVIDED BY THE CREATORS, STAFF, VOLUNTEERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR \ + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND \ + FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE CREATORS, STAFF, VOLUNTEERS OR \ + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL \ + DAMAGES HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR \ + TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS GAME, EVEN IF \ + ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. FOR THE AVOIDANCE OF DOUBT, THIS DISCLAIMER EXTENDS TO ANY \ + USER-SUPPLIED CONTENT THAT THE GAME MAY EXPOSE.", + "I acknowledge that this game allows user-supplied content, and that while staff endeavour to \ + moderate it, I may encounter content that is distressing and/or outside community standards. \ + I agree that I will not use the game or any services provided in connection with it to transmit content \ + which is illegal (including by virtue of infringing copyright), infringes on the rights of others, \ + is personally identifying information, or is objectionable or abhorrent (including, without \ + limitation, any content related to sexual violence, real or fictional children under 18, bestiality, \ + the promotion or glorification of proscribed drug use, or fetishes that involve degrading or \ + inflicting pain in someone for the enjoyment of others). I agree to defend, indemnify, and hold \ + harmless the creators, staff, volunteers and contributors in any matter relating to content sent \ + (or re-sent) by me, in any matter arising from the game sending content to me, and in any matter \ + consequential to sharing my password, using an insecure password, or otherwise allowing or taking \ + inadequate measures to prevent another player from logging in as one or more of my characters.", + "I certify that I am not, to my knowledge, currently banned from the game. I agree not to sustain any \ + contact with another player that is unwelcome, or to take any action for the purpose of harassment or \ + limiting the game for other players without all affected players' consent. I agree not to allow any \ + other person to play as my character, not to have more than 5 characters active (available to log \ + in as) at any one time, not to be logged in as more than one character at any instant in time, and \ + not to use any of my characters to help another character of mine in the game.", +]; + +fn user_mut<'a>(ctx: &'a mut VerbContext) -> UResult<&'a mut User> { + match ctx.user_dat.as_mut() { + None => Err("Checked agreements before user logged in, which is a logic error")?, + Some(user_dat) => Ok(user_dat) + } +} + +fn terms<'a>(ctx: &'a VerbContext<'a>) -> UResult<&'a UserTermData> { + match ctx.user_dat.as_ref() { + None => Err("Checked agreements before user logged in, which is a logic error")?, + Some(user_dat) => Ok(&user_dat.terms) + } +} + + +fn first_outstanding_agreement(ctx: &VerbContext) -> UResult> { + let existing_terms = &terms(ctx)?.accepted_terms; + for agreement in REQUIRED_AGREEMENTS { + let shortcode = + base64::encode(ring::digest::digest(&ring::digest::SHA256, + agreement.as_bytes()))[0..20].to_owned(); + match existing_terms.get(&shortcode) { + None => { return Ok(Some((agreement.to_owned(), shortcode))); } + Some(_) => {} + } + } + Ok(None) +} + +pub async fn check_and_notify_accepts<'a, 'b>(ctx: &'a mut VerbContext<'b>) -> UResult where 'b: 'a { + match first_outstanding_agreement(ctx)? { + None => { + user_mut(ctx)?.terms.terms_complete = true; + Ok(true) + } + Some((text, hash)) => { + let user = user_mut(ctx)?; + user.terms.terms_complete = false; + user.terms.last_presented_term = Some(hash); + ctx.trans.queue_for_session(ctx.session, Some(&format!(ansi!( + "Please review the following:\r\n\ + {}\r\n\ + Type agree to accept. If you can't or don't agree, you \ + unfortunately can't play, so type quit to log off.\r\n"), + text))).await?; + Ok(false) + } + } +} + +#[async_trait] +impl UserVerb for Verb { + async fn handle(self: &Self, _ctx: &mut VerbContext, _verb: &str, _remaining: &str) -> UResult<()> { + Ok(()) + } +} +static VERB_INT: Verb = Verb; +pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; diff --git a/blastmud_game/src/message_handler/user_commands/help.rs b/blastmud_game/src/message_handler/user_commands/help.rs index eb3be1ff..9c33ef24 100644 --- a/blastmud_game/src/message_handler/user_commands/help.rs +++ b/blastmud_game/src/message_handler/user_commands/help.rs @@ -6,7 +6,23 @@ use async_trait::async_trait; use ansi_macro::ansi; use phf::phf_map; -static HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { +static ALWAYS_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { + "" => + ansi!("You are supposed to replace topicname> with the topic you want \ + to learn about. Example:\r\n\ + \thelp register will tell you about the register command.") +}; + +static UNREGISTERED_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { + "" => + ansi!("Type help topicname> to learn about a topic. Most \ + commands can be used as a topicname.\r\n\ + Topics of interest to unregistered users:\r\n\ + \tregister\tLearn about the register command.\r\n\ + \tlogin\tLearn how to log in as an existing user.\r\n"), +}; + +static REGISTERED_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { "" => ansi!("Type help topicname> to learn about a topic. Most \ commands can be used as a topicname.\r\n\ @@ -30,10 +46,19 @@ pub struct Verb; impl UserVerb for Verb { async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { let mut help = None; - if !ctx.session_dat.less_explicit_mode { - help = help.or_else(|| EXPLICIT_HELP_PAGES.get(remaining)) + let is_unregistered = match ctx.user_dat { + None => true, + Some(user_dat) => !user_dat.terms.terms_complete + }; + if is_unregistered { + help = help.or_else(|| UNREGISTERED_HELP_PAGES.get(remaining)); + } else { + help = help.or_else(|| REGISTERED_HELP_PAGES.get(remaining)); + if !ctx.session_dat.less_explicit_mode { + help = help.or_else(|| EXPLICIT_HELP_PAGES.get(remaining)) + } } - help = help.or_else(|| HELP_PAGES.get(remaining)); + help = help.or_else(|| ALWAYS_HELP_PAGES.get(remaining)); let help_final = help.ok_or( UserError("No help available on that".to_string()))?; ctx.trans.queue_for_session(ctx.session, diff --git a/blastmud_game/src/message_handler/user_commands/register.rs b/blastmud_game/src/message_handler/user_commands/register.rs index 4f4bc4e3..ab938b77 100644 --- a/blastmud_game/src/message_handler/user_commands/register.rs +++ b/blastmud_game/src/message_handler/user_commands/register.rs @@ -1,16 +1,53 @@ use super::{VerbContext, UserVerb, UserVerbRef, UResult}; use async_trait::async_trait; use super::{user_error, parsing::parse_username}; +use crate::models::{user::User, item::Item}; +use chrono::Utc; +use ansi_macro::ansi; pub struct Verb; #[async_trait] impl UserVerb for Verb { - async fn handle(self: &Self, _ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { - let (username, password) = match parse_username(remaining) { + async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { + let (username, mut password) = match parse_username(remaining) { Err(e) => user_error("Invalid username: ".to_owned() + e)?, Ok(r) => r }; - let _pwhash = bcrypt::hash(password, 10); + password = password.trim(); + if ctx.trans.find_by_username(username).await?.is_some() { + user_error("Username already exists".to_owned())?; + } + if password.contains(" ") || password.contains("\t") { + user_error("To avoid future confusion, password can't contain spaces / tabs".to_owned())?; + } else if password.len() < 6 { + user_error("Password must be 6 characters long or longer".to_owned())?; + } + + let player_item_id = ctx.trans.create_item(&Item { + item_type: "player".to_owned(), + item_code: username.to_lowercase(), + display: username.to_owned(), + location: "room/chargen_room".to_owned(), + ..Item::default() + }).await?; + let password_hash = bcrypt::hash(password, 10).expect("hash not to fail"); + let user_dat = User { + username: username.to_owned(), + password_hash: password_hash.to_owned(), + player_item_id, + registered_at: Some(Utc::now()), + ..User::default() + }; + *ctx.user_dat = Some(user_dat); + + ctx.trans.queue_for_session( + ctx.session, + Some(&format!(ansi!("Welcome {}, you are now officially registered.\r\n"), + &username)) + ).await?; + super::agree::check_and_notify_accepts(ctx).await?; + ctx.trans.create_user(ctx.session, ctx.user_dat.as_ref().unwrap()).await?; + Ok(()) } } diff --git a/blastmud_game/src/models/item.rs b/blastmud_game/src/models/item.rs index 632925f6..ea2ba714 100644 --- a/blastmud_game/src/models/item.rs +++ b/blastmud_game/src/models/item.rs @@ -44,6 +44,8 @@ pub enum LocationActionType { pub struct Item { pub item_code: String, pub item_type: String, + pub display: String, + pub display_less_explicit: Option, pub location: String, // Item reference as item_type/item_code. pub action_type: LocationActionType, pub presence_target: Option, // e.g. what are they sitting on. @@ -54,3 +56,22 @@ pub struct Item { pub total_skills: BTreeMap, pub temporary_buffs: Vec, } + +impl Default for Item { + fn default() -> Self { + Item { + item_code: "unset".to_owned(), + item_type: "unset".to_owned(), + display: "Item".to_owned(), + display_less_explicit: None, + location: "room/storage".to_owned(), + action_type: LocationActionType::Normal, + presence_target: None, + is_static: false, + total_xp: 0, + total_stats: BTreeMap::new(), + total_skills: BTreeMap::new(), + temporary_buffs: Vec::new() + } + } +} diff --git a/blastmud_game/src/models/user.rs b/blastmud_game/src/models/user.rs index d7556b6b..56a7d65f 100644 --- a/blastmud_game/src/models/user.rs +++ b/blastmud_game/src/models/user.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct UserTermData { pub accepted_terms: BTreeMap>, + pub terms_complete: bool, // Recalculated on accept and login. pub last_presented_term: Option, } @@ -65,7 +66,7 @@ pub enum StatType { pub struct User { pub username: String, pub password_hash: String, // bcrypted. - pub player_item_id: u64, + pub player_item_id: i64, pub registered_at: Option>, pub banned_until: Option>, pub abandoned_at: Option>, @@ -83,6 +84,7 @@ impl Default for UserTermData { fn default() -> Self { UserTermData { accepted_terms: BTreeMap::new(), + terms_complete: false, last_presented_term: None } } diff --git a/schema/schema.sql b/schema/schema.sql index 7334867a..e124655b 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -26,6 +26,7 @@ CREATE INDEX item_by_loc ON items ((details->>'location')); CREATE INDEX item_by_static ON items ((cast(details->>'is_static' as boolean))); CREATE TABLE users ( + -- Username here is all lower case, but details has correct case version. username TEXT NOT NULL PRIMARY KEY, current_session UUID REFERENCES sessions(session), current_listener UUID REFERENCES listeners(listener),