diff --git a/WORKLOG.md b/WORKLOG.md
index bc8e41ab..a2ff9398 100644
--- a/WORKLOG.md
+++ b/WORKLOG.md
@@ -6,7 +6,9 @@
* error log
* account lookup w/ pw reset
- * nice to have
+ * treats
+ * constructs jiggle when clicked
+ * background colour changes depending on time of day
* bot game grind
diff --git a/client/src/components/account.management.jsx b/client/src/components/account.management.jsx
index 3561094e..59d52d94 100644
--- a/client/src/components/account.management.jsx
+++ b/client/src/components/account.management.jsx
@@ -28,6 +28,17 @@ const addState = connect(
.catch(error => errorToast(error));
}
+ function setEmail(email) {
+ postData('/account/email', { email })
+ .then(res => res.json())
+ .then(data => {
+ if (!data.success) return errorToast(data.error_message);
+ infoToast('Email set. Please confirm your address.');
+ return true;
+ })
+ .catch(error => errorToast(error));
+ }
+
function logout() {
postData('/account/logout').then(() => window.location.reload(true));
}
@@ -42,6 +53,7 @@ const addState = connect(
ping,
logout,
setPassword,
+ setEmail,
sendConstructSpawn,
};
},
@@ -54,6 +66,7 @@ class AccountStatus extends Component {
this.state = {
setPassword: { current: '', password: '', confirm: ''},
+ email: null,
};
}
@@ -63,6 +76,7 @@ class AccountStatus extends Component {
ping,
logout,
setPassword,
+ setEmail,
sendConstructSpawn,
} = args;
@@ -84,13 +98,13 @@ class AccountStatus extends Component {
Subscription
{account.subscribed ? 'some date' : 'unsubscribed'}
-
+
-
+
- - Current Email
+ - Recovery Email
- {account.email ? account.email : 'No email set'}
- Status
- {account.email_confirmed ? 'Confirmed' : 'Unconfirmed'}
@@ -99,9 +113,11 @@ class AccountStatus extends Component {
class="login-input"
type="email"
name="email"
- placeholder="new email"
+ value={this.state.email}
+ onInput={linkState(this, 'email')}
+ placeholder="recovery@email.tld"
/>
-
+
diff --git a/ops/migrations/20180913000513_create_accounts.js b/ops/migrations/20180913000513_create_accounts.js
index 06961ad3..3184ff08 100755
--- a/ops/migrations/20180913000513_create_accounts.js
+++ b/ops/migrations/20180913000513_create_accounts.js
@@ -6,7 +6,9 @@ exports.up = async knex => {
table.string('name', 42).notNullable().unique();
table.string('password').notNullable();
- table.string('token', 64).notNullable();
+ table.string('token', 64)
+ .notNullable()
+ .index();
table.timestamp('token_expiry').notNullable();
table.bigInteger('balance')
@@ -18,7 +20,6 @@ exports.up = async knex => {
.notNullable();
table.index('name');
- table.index('id');
});
await knex.schema.raw(`
diff --git a/ops/migrations/20190825172701_email.js b/ops/migrations/20190825172701_email.js
new file mode 100644
index 00000000..6fd8408b
--- /dev/null
+++ b/ops/migrations/20190825172701_email.js
@@ -0,0 +1,35 @@
+exports.up = async knex => {
+ await knex.schema.createTable('email', table => {
+ table.timestamps(true, true);
+
+ table.uuid('id')
+ .primary();
+ .index();
+
+ table.uuid('account')
+ .notNullable()
+ .index();
+
+ table.foreign('account')
+ .references('id')
+ .inTable('accounts')
+ .onDelete('CASCADE');
+
+ table.string('email', 128)
+ .unique()
+ .notNullable()
+ .index();
+
+ table.string('confirm_token', 64)
+ .notNullable()
+ .index();
+
+ table.bool('confirmed')
+ .notNullable()
+ .defaultTo(false);
+ });
+
+ return true;
+};
+
+exports.down = async () => {};
\ No newline at end of file
diff --git a/server/Cargo.toml b/server/Cargo.toml
index abf335ad..f66c0f87 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -27,6 +27,7 @@ fern = { version = "0.5", features = ["colored"] }
iron = "0.6"
bodyparser = "0.8"
+urlencoded = "0.6"
persistent = "0.4"
router = "0.6"
mount = "0.4"
@@ -34,4 +35,7 @@ cookie = "0.12"
crossbeam-channel = "0.3"
ws = "0.8"
+lettre = "0.9"
+lettre_email = "0.9"
+
stripe-rust = { version = "0.10.4", features = ["webhooks"] }
diff --git a/server/src/events.rs b/server/src/events.rs
index 95cf18e6..eb5e12d5 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -15,6 +15,7 @@ use instance;
use pg::{Db, PgPool};
use rpc::RpcMessage;
use warden::{GameEvent};
+use mail::Mail;
pub type EventsTx = Sender
;
type Id = usize;
@@ -34,6 +35,7 @@ pub struct Events {
pub tx: Sender,
rx: Receiver,
+ mail: Sender,
warden: Sender,
queue: Option,
@@ -62,11 +64,12 @@ struct WsClient {
}
impl Events {
- pub fn new(tx: Sender, rx: Receiver, warden: Sender) -> Events {
+ pub fn new(tx: Sender, rx: Receiver, warden: Sender, mail: Sender) -> Events {
Events {
tx,
rx,
warden,
+ mail,
queue: None,
clients: HashMap::new(),
}
@@ -150,7 +153,7 @@ impl Events {
},
Event::Push(id, msg) => {
- info!("push id={:?} msg={:?}", id, msg);
+ info!("push id={:?}", id);
let mut subs = 0;
let mut dead = vec![];
diff --git a/server/src/http.rs b/server/src/http.rs
index cc6f3d0a..b845dc19 100644
--- a/server/src/http.rs
+++ b/server/src/http.rs
@@ -6,7 +6,10 @@ use iron::prelude::*;
use iron::status;
use iron::typemap::Key;
use iron::mime::Mime;
+use iron::modifiers::Redirect;
+use iron::Url;
use iron::{typemap, BeforeMiddleware,AfterMiddleware};
+use urlencoded::UrlEncodedQuery;
use persistent::Read;
use router::Router;
use mount::{Mount};
@@ -14,6 +17,7 @@ use serde::{Serialize, Deserialize};
use acp;
use account;
+use mail;
use pg::PgPool;
use payments::{stripe};
@@ -290,6 +294,68 @@ fn set_password(req: &mut Request) -> IronResult {
}
}
+#[derive(Debug,Clone,Deserialize)]
+struct SetEmail {
+ email: String,
+}
+
+fn set_email(req: &mut Request) -> IronResult {
+ let state = req.get::>().unwrap();
+ let params = match req.get::>() {
+ Ok(Some(b)) => b,
+ _ => return Err(IronError::from(MnmlHttpError::BadRequest)),
+ };
+
+ match req.extensions.get::() {
+ Some(a) => {
+ let db = state.pool.get().or(Err(MnmlHttpError::DbError))?;
+ let mut tx = db.transaction().or(Err(MnmlHttpError::DbError))?;
+
+ let id = match mail::insert(&mut tx, a.id, ¶ms.email) {
+ Ok(res) => res,
+ Err(e) => {
+ warn!("{:?}", e);
+ return Err(MnmlHttpError::ServerError.into());
+ },
+ };
+
+ info!("email added id={:?} account={:?} email={:?}", id, a, params.email);
+
+ tx.commit().or(Err(MnmlHttpError::ServerError))?;
+
+ Ok(json_response(status::Ok, Json::Message("email set. confirmation required".to_string())))
+ },
+ None => Err(IronError::from(MnmlHttpError::Unauthorized)),
+ }
+}
+
+fn confirm(req: &mut Request) -> IronResult {
+ let state = req.get::>().unwrap();
+
+ match req.get_ref::() {
+ Ok(ref hashmap) => {
+ let db = state.pool.get().or(Err(MnmlHttpError::DbError))?;
+ let mut tx = db.transaction().or(Err(MnmlHttpError::DbError))?;
+
+ let token = match hashmap.get("confirm_token") {
+ Some(t) => &t[0],
+ None => return Err(IronError::from(MnmlHttpError::BadRequest)),
+ };
+
+ let confirmation = match mail::confirm_email(&mut tx, token.to_string()) {
+ Ok(c) => c,
+ Err(_) => return Err(IronError::from(MnmlHttpError::NotFound))
+ };
+
+ info!("email confirmed email={:?} account={:?}", confirmation.0, confirmation.1);
+
+ tx.commit().or(Err(MnmlHttpError::ServerError))?;
+ Ok(Response::with((status::Found, Redirect(Url::parse("https://mnml.gg").unwrap()))))
+ },
+ Err(_) => Err(IronError::from(MnmlHttpError::BadRequest)),
+ }
+}
+
const MAX_BODY_LENGTH: usize = 1024 * 1024 * 10;
pub struct State {
@@ -306,7 +372,8 @@ fn account_mount() -> Router {
router.post("logout", logout, "logout");
router.post("register", register, "register");
router.post("password", set_password, "set_password");
- router.post("email", logout, "email");
+ router.post("email", set_email, "set_email");
+ router.post("email/confirm/", confirm, "confirm");
router
}
diff --git a/server/src/mail.rs b/server/src/mail.rs
new file mode 100644
index 00000000..7fd3cb00
--- /dev/null
+++ b/server/src/mail.rs
@@ -0,0 +1,148 @@
+use std::env;
+
+use uuid::Uuid;
+use rand::{thread_rng, Rng};
+use rand::distributions::Alphanumeric;
+use std::iter;
+use postgres::transaction::Transaction;
+
+use failure::Error;
+use failure::{err_msg, format_err};
+
+use crossbeam_channel::Receiver;
+use lettre::smtp::authentication::{Credentials, Mechanism};
+use lettre::smtp::ConnectionReuseParameters;
+use lettre::smtp::error::Error as MailError;
+use lettre::smtp::extension::ClientId;
+use lettre::smtp::response::Response;
+use lettre::{SendableEmail, SmtpClient, SmtpTransport, Transport};
+use lettre_email::Email;
+
+pub enum Mail {
+ Recover { email: String, name: String, token: String },
+ Confirm { email: String, name: String, token: String },
+}
+
+// create link that will set a token
+// put msg saying pls reset your password
+// redirect to main page cause cbf
+
+fn recover(email: String, name: String, token: String) -> SendableEmail {
+ Email::builder()
+ .from("machines@mnml.gg")
+ .to(email)
+ .subject("recovery phase")
+ .text(format!("{:},\nyour token has been reset to\n{:}\n", name, token))
+ .build()
+ .unwrap()
+ .into()
+}
+
+fn confirm(email: String, name: String, token: String) -> SendableEmail {
+ let confirm_body = format!("{:},
+ please click the link below to confirm your email
+ http://mnml.gg/api/account/email/confirm/{:}
+ ", name, token);
+
+ Email::builder()
+ .from("machines@mnml.gg")
+ .to(email)
+ .subject("recovery phase")
+ .text(confirm_body)
+ .build()
+ .unwrap()
+ .into()
+}
+
+fn send_mail(mailer: &mut SmtpTransport, mail: Mail) -> Result {
+ let msg = match mail {
+ Mail::Recover { email, name, token } => recover(email, name, token),
+ Mail::Confirm { email, name, token } => confirm(email, name, token),
+ };
+
+ mailer.send(msg)
+}
+
+pub fn confirm_email(tx: &mut Transaction, token: String) -> Result<(String, Uuid), Error> {
+ let query = "
+ UPDATE emails
+ SET confirmed = true, updated_at = now()
+ WHERE token = $1
+ RETURNING id, email, account
+ ";
+
+ let result = tx
+ .query(query, &[&token])?;
+
+ let row = result.iter().next()
+ .ok_or(format_err!("token not found {:?}", token))?;
+
+ let _id: Uuid = row.get(0);
+ let email: String = row.get(1);
+ let account: Uuid = row.get(2);
+
+ return Ok((email, account));
+}
+
+pub fn insert(tx: &mut Transaction, account: Uuid, email: &String) -> Result {
+ let id = Uuid::new_v4();
+
+ let mut rng = thread_rng();
+ let token: String = iter::repeat(())
+ .map(|()| rng.sample(Alphanumeric))
+ .take(64)
+ .collect();
+
+ let query = "
+ INSERT INTO emails (id, account, email, token)
+ VALUES ($1, $2, $3, $4);
+ ";
+
+ let result = tx
+ .query(query, &[&id, &account, &email, &token])?;
+
+ let row = match result.iter().next() {
+ Some(row) => row,
+ None => return Err(err_msg("no email inserted")),
+ };
+
+ return Ok(id);
+}
+
+pub fn listen(rx: Receiver) {
+ let sender = env::var("MAIL_ADDRESS")
+ .expect("MAIL_ADDRESS must be set");
+
+ let password = env::var("MAIL_PASSWORD")
+ .expect("MAIL_PASSWORD must be set");
+
+ let domain = env::var("MAIL_DOMAIN")
+ .expect("MAIL_DOMAIN must be set");
+
+ let mut mailer = SmtpClient::new_simple("smtp.gmail.com").unwrap()
+ .hello_name(ClientId::Domain(domain))
+ .credentials(Credentials::new(sender, password))
+ .smtp_utf8(true)
+ .authentication_mechanism(Mechanism::Plain)
+ .connection_reuse(ConnectionReuseParameters::ReuseUnlimited)
+ .transport();
+
+ info!("mail connected");
+
+ loop {
+ match rx.recv() {
+ Ok(m) => match send_mail(&mut mailer, m) {
+ Ok(r) => info!("{:?}", r),
+ Err(e) => warn!("{:?}", e),
+ },
+ Err(e) => {
+ error!("{:?}", e);
+ panic!("mail thread cannot continue");
+ },
+ };
+ }
+
+ // Explicitly close the SMTP transaction as we enabled connection reuse
+ // mailer.close();
+}
+
diff --git a/server/src/main.rs b/server/src/main.rs
index 1d24fe3e..a47fc29f 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -21,11 +21,15 @@ extern crate stripe;
extern crate iron;
extern crate bodyparser;
+extern crate urlencoded;
extern crate persistent;
extern crate router;
extern crate mount;
extern crate cookie;
+extern crate lettre;
+extern crate lettre_email;
+
extern crate ws;
extern crate crossbeam_channel;
@@ -37,6 +41,7 @@ mod game;
mod instance;
mod item;
mod img;
+mod mail;
mod mob;
mod mtx;
mod names;
@@ -116,8 +121,8 @@ fn setup_logger() -> Result<(), fern::InitError> {
}
fn main() {
- dotenv::from_path(Path::new("/etc/mnml/server.conf")).ok();
setup_logger().unwrap();
+ dotenv::from_path(Path::new("/etc/mnml/gs.conf")).ok();
let pool = pg::create_pool();
let http_pool = pool.clone();
@@ -130,9 +135,11 @@ fn main() {
let events_warden_tx = warden_tx.clone();
let warden_tick_tx = warden_tx.clone();
+ let (mail_tx, mail_rx) = unbounded();
+
// create a clone of the tx so ws handler can tell events
// about connection status
- let events = events::Events::new(events_tx, events_rx, events_warden_tx);
+ let events = events::Events::new(events_tx, events_rx, events_warden_tx, mail_tx);
let warden = warden::Warden::new(warden_tx, warden_rx, events.tx.clone(), pool.clone());
let pg_pool = pool.clone();
@@ -142,6 +149,7 @@ fn main() {
spawn(move || warden::upkeep_tick(warden_tick_tx));
spawn(move || pg::listen(pg_pool, pg_events_tx));
spawn(move || events.listen());
+ spawn(move || mail::listen(mail_rx));
// the main thread becomes this ws listener
let rpc_pool = pool.clone();