Start system to accept required terms to continue.

This commit is contained in:
Condorra 2022-12-27 00:20:09 +11:00
parent 4055a856f4
commit 16bd49f160
8 changed files with 255 additions and 17 deletions

View File

@ -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<Option<User>> {
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<i64> {
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(())

View File

@ -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<A> = Result<A, CommandHandlingError>;
impl From<&str> for CommandHandlingError {
fn from(input: &str) -> CommandHandlingError {
SystemError(Box::from(input))
}
}
impl From<Box<dyn std::error::Error + Send + Sync>> for CommandHandlingError {
fn from(input: Box<dyn std::error::Error + Send + Sync>) -> 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 <bold>help<reset>\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(())
}

View File

@ -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<Option<(String, String)>> {
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<bool> 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 <bold>agree<reset> to accept. If you can't or don't agree, you \
unfortunately can't play, so type <bold>quit<reset> 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;

View File

@ -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! {
"<topicname>" =>
ansi!("You are supposed to replace <lt>topicname> with the topic you want \
to learn about. Example:\r\n\
\t<bold>help register<reset> will tell you about the register command.")
};
static UNREGISTERED_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! {
"" =>
ansi!("Type <bold>help <lt>topicname><reset> to learn about a topic. Most \
commands can be used as a topicname.\r\n\
Topics of interest to unregistered users:\r\n\
\t<bold>register<reset>\tLearn about the <bold>register<reset> command.\r\n\
\t<bold>login<reset>\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 <bold>help <lt>topicname><reset> 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;
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,

View File

@ -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 <bold>{}<reset>, 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(())
}
}

View File

@ -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<String>,
pub location: String, // Item reference as item_type/item_code.
pub action_type: LocationActionType,
pub presence_target: Option<String>, // e.g. what are they sitting on.
@ -54,3 +56,22 @@ pub struct Item {
pub total_skills: BTreeMap<SkillType, u64>,
pub temporary_buffs: Vec<Buff>,
}
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()
}
}
}

View File

@ -5,6 +5,7 @@ use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserTermData {
pub accepted_terms: BTreeMap<String, DateTime<Utc>>,
pub terms_complete: bool, // Recalculated on accept and login.
pub last_presented_term: Option<String>,
}
@ -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<DateTime<Utc>>,
pub banned_until: Option<DateTime<Utc>>,
pub abandoned_at: Option<DateTime<Utc>>,
@ -83,6 +84,7 @@ impl Default for UserTermData {
fn default() -> Self {
UserTermData {
accepted_terms: BTreeMap::new(),
terms_complete: false,
last_presented_term: None
}
}

View File

@ -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),