From df1ccdb8cdf971641007ec4b2f98ac5cd738b2fc Mon Sep 17 00:00:00 2001 From: ntr Date: Sun, 25 Aug 2019 18:47:40 +1000 Subject: [PATCH] email http functions --- WORKLOG.md | 4 +- client/src/components/account.management.jsx | 26 ++- .../20180913000513_create_accounts.js | 5 +- ops/migrations/20190825172701_email.js | 35 +++++ server/Cargo.toml | 4 + server/src/events.rs | 7 +- server/src/http.rs | 69 +++++++- server/src/mail.rs | 148 ++++++++++++++++++ server/src/main.rs | 12 +- 9 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 ops/migrations/20190825172701_email.js create mode 100644 server/src/mail.rs 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();