diff --git a/Cargo.lock b/Cargo.lock index 195655f..97dd29e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2422,6 +2422,7 @@ dependencies = [ "dotenvy", "lettre", "maud", + "rand 0.8.5", "serde", "serenity", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index 5bbb73a..0732349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,9 @@ dotenvy = "0.15.7" # For sqlite sqlx = { version = "0.7.1", features = [ "runtime-tokio", "sqlite" ] } +# create random strings +rand = "0.8.5" + # fancy time stuff chrono = "0.4.26" diff --git a/src/bin/update_data.rs b/src/bin/update_data.rs index 315ced6..cd6d985 100644 --- a/src/bin/update_data.rs +++ b/src/bin/update_data.rs @@ -43,6 +43,7 @@ impl From<&RecordCSV> for Wolves { Self { id_wolves: input.mem_id.to_owned(), email: input.email.to_owned(), + verified: false, discord: None, minecraft: None, } @@ -77,9 +78,11 @@ fn get_csv(config: &Config) -> Result, Box> { continue; } + let mut tmp = ServerMembers::from(&record); + tmp.server = config.skynet_server; result.push(Csv { wolves: Wolves::from(&record), - server_members: ServerMembers::from(&record), + server_members: tmp, }); } } diff --git a/src/commands/link_email.rs b/src/commands/link_email.rs index 85e3801..bde0a7c 100644 --- a/src/commands/link_email.rs +++ b/src/commands/link_email.rs @@ -1,20 +1,38 @@ -use serenity::builder::CreateApplicationCommand; -use serenity::client::Context; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; -use serenity::model::id::GuildId; -use serenity::model::prelude::command::CommandOptionType; -use serenity::model::prelude::interaction::application_command::CommandDataOptionValue; -use skynet_discord_bot::{get_now_iso, DataBase, Wolves}; +use lettre::{ + message::{header, MultiPart, SinglePart}, + transport::smtp::{self, authentication::Credentials}, + Message, SmtpTransport, Transport, +}; +use maud::html; +use serenity::{ + builder::CreateApplicationCommand, + client::Context, + model::{ + application::interaction::application_command::ApplicationCommandInteraction, + prelude::{command::CommandOptionType, interaction::application_command::CommandDataOptionValue}, + }, +}; +use skynet_discord_bot::{get_now_iso, random_string, Config, DataBase, Wolves, WolvesVerify}; use sqlx::{Pool, Sqlite}; -pub async fn run(options: &ApplicationCommandInteraction, ctx: &Context) -> String { +pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String { let db_lock = { let data_read = ctx.data.read().await; - data_read.get::().expect("Expected Config in TypeMap.").clone() + data_read.get::().expect("Expected Databse in TypeMap.").clone() }; let db = db_lock.read().await; - let option = options + let config_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected Config in TypeMap.").clone() + }; + let config = config_lock.read().await; + + if get_server_member_discord(&db, &command.user.name).await.is_some() { + return "Already linked".to_string(); + } + + let option = command .data .options .get(0) @@ -23,11 +41,44 @@ pub async fn run(options: &ApplicationCommandInteraction, ctx: &Context) -> Stri .as_ref() .expect("Expected email object"); - if let CommandDataOptionValue::String(email) = option { - format!("Email is {}, user is {} {:?}", email, options.user.name, options.guild_id) + let email = if let CommandDataOptionValue::String(email) = option { + email } else { - "Please provide a valid user".to_string() + return "Please provide a valid user".to_string(); + }; + + // check if email exists + let details = match get_server_member_email(&db, email).await { + None => return "Please check is the same as on https://ulwolves.ie/".to_string(), + Some(x) => x, + }; + + if details.verified { + return "Email already verified".to_string(); } + + db_pending_clear_expired(&db).await; + + // send mail + if get_from_db(&db, &command.user.name).await.is_some() { + return "Please check email".to_string(); + } + + // generate a auth key + let auth = random_string(20); + match send_mail(&config, &details, &auth, &command.user.name) { + Ok(_) => match save_to_db(&db, &details, &auth, &command.user.name).await { + Ok(_) => {} + Err(e) => { + return format!("Unable to save to db {} {e:?}", &details.email); + } + }, + Err(e) => { + return format!("Unable to send mail to {} {e:?}", &details.email); + } + } + + format!("Verification email sent to {}, user is {} {:?}", email, command.user.name, command.guild_id) } pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { @@ -37,22 +88,143 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .create_option(|option| option.name("email").description("UL Wolves Email").kind(CommandOptionType::String).required(true)) } -async fn get_server_member_bulk(db: &Pool, server: &GuildId) -> Vec { +async fn get_server_member_discord(db: &Pool, user: &str) -> Option { sqlx::query_as::<_, Wolves>( r#" SELECT * - FROM server_members - JOIN wolves ON server_members.id_wolves = wolves.id_wolves - WHERE ( - server = ? - AND discord IS NOT NULL - AND expiry > ? - ) + FROM wolves + WHERE discord = "#, ) - .bind(*server.as_u64() as i64) - .bind(get_now_iso(true)) - .fetch_all(db) + .bind(user) + .fetch_one(db) + .await + .ok() +} + +async fn get_server_member_email(db: &Pool, email: &str) -> Option { + sqlx::query_as::<_, Wolves>( + r#" + SELECT * + FROM wolves + WHERE email = ? + "#, + ) + .bind(email) + .fetch_one(db) + .await + .ok() +} + +fn send_mail(config: &Config, email: &Wolves, auth: &str, user: &str) -> Result { + let mail = &email.email; + let discord = "https://discord.skynet.ie"; + let sender = format!("UL Computer Society <{}>", &config.mail_user); + + // Create the html we want to send. + let html = html! { + head { + title { "Hello from Skynet!" } + style type="text/css" { + "h2, h4 { font-family: Arial, Helvetica, sans-serif; }" + } + } + div style="display: flex; flex-direction: column; align-items: center;" { + h2 { "Hello from Skynet!" } + // Substitute in the name of our recipient. + p { "Hi " (user) "," } + p { + "Please use " pre { "/verify " (auth)} " to verify your discord account." + } + p { + "If you have issues please refer to our Discord server:" + br; + a href=(discord) { (discord) } + } + p { + "Skynet Team" + br; + "UL Computer Society" + } + } + }; + + let body_text = format!( + r#" + Hi {user} + + Please use "/verify {auth}" to verify your discord account. + + If you have issues please refer to our Discord server: + {discord} + + Skynet Team + UL Computer Society + "# + ); + + // Build the message. + let email = Message::builder() + .from(sender.parse().unwrap()) + .to(mail.parse().unwrap()) + .subject("Skynet-Discord: Link Wolves.") + .multipart( + // This is composed of two parts. + // also helps not trip spam settings (uneven number of url's + MultiPart::alternative() + .singlepart(SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(body_text)) + .singlepart(SinglePart::builder().header(header::ContentType::TEXT_HTML).body(html.into_string())), + ) + .expect("failed to build email"); + + let creds = Credentials::new(config.mail_user.clone(), config.mail_pass.clone()); + + // Open a remote connection to gmail using STARTTLS + let mailer = SmtpTransport::starttls_relay(&config.mail_smtp).unwrap().credentials(creds).build(); + + // Send the email + mailer.send(&email) +} + +async fn db_pending_clear_expired(pool: &Pool) -> Option { + sqlx::query_as::<_, WolvesVerify>( + r#" + DELETE + FROM wolves_verify + WHERE date_expiry < ? + "#, + ) + .bind(get_now_iso(true)) + .fetch_one(pool) + .await + .ok() +} + +async fn get_from_db(db: &Pool, user: &str) -> Option { + sqlx::query_as::<_, WolvesVerify>( + r#" + SELECT * + FROM wolves_verify + WHERE discord = + "#, + ) + .bind(user) + .fetch_one(db) + .await + .ok() +} + +async fn save_to_db(db: &Pool, record: &Wolves, auth: &str, user: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, WolvesVerify>( + " + INSERT INTO wolves_verify (email, discord, auth_code, date_expiry) + VALUES (?1, ?2, ?3, ?4) + ", + ) + .bind(record.email.to_owned()) + .bind(user) + .bind(auth.to_owned()) + .bind(get_now_iso(false)) + .fetch_optional(db) .await - .unwrap_or_default() } diff --git a/src/lib.rs b/src/lib.rs index 5777f39..617924e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ use serenity::{ }; use chrono::{Datelike, SecondsFormat, Utc}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; use sqlx::{ sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteRow}, Error, FromRow, Pool, Row, Sqlite, @@ -127,23 +128,21 @@ impl<'r> FromRow<'r, SqliteRow> for ServerMembers { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, sqlx::FromRow)] pub struct Wolves { pub id_wolves: String, pub email: String, + pub verified: bool, pub discord: Option, pub minecraft: Option, } -impl<'r> FromRow<'r, SqliteRow> for Wolves { - fn from_row(row: &'r SqliteRow) -> Result { - Ok(Self { - id_wolves: row.try_get("id_wolves")?, - email: row.try_get("email")?, - discord: row.try_get("discord")?, - minecraft: row.try_get("minecraft")?, - }) - } +#[derive(Debug, Clone, Deserialize, Serialize, sqlx::FromRow)] +pub struct WolvesVerify { + pub email: String, + pub discord: String, + pub auth_code: String, + pub date_expiry: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -202,7 +201,8 @@ pub async fn db_init(config: &Config) -> Result, Error> { id_wolves text PRIMARY KEY, email text not null, discord text, - minecraft text + minecraft text, + verified integer DEFAULT FALSE )", ) .execute(&pool) @@ -210,6 +210,21 @@ pub async fn db_init(config: &Config) -> Result, Error> { sqlx::query("CREATE INDEX IF NOT EXISTS index_discord ON wolves (discord)").execute(&pool).await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS wolves_verify ( + email text PRIMARY KEY, + discord text not null, + auth_code text not null, + date_expiry text not null + )", + ) + .execute(&pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS index_date_expiry ON wolves_verify (date_expiry)") + .execute(&pool) + .await?; + sqlx::query( "CREATE TABLE IF NOT EXISTS server_members ( server integer not null, @@ -304,3 +319,7 @@ pub fn get_now_iso(short: bool) -> String { now.to_rfc3339_opts(SecondsFormat::Millis, true) } } + +pub fn random_string(len: usize) -> String { + thread_rng().sample_iter(&Alphanumeric).take(len).map(char::from).collect() +} diff --git a/src/main.rs b/src/main.rs index 4cf4200..24f291e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,7 @@ impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { if let Interaction::ApplicationCommand(command) = interaction { + let _ = command.defer_ephemeral(&ctx.http).await; //println!("Received command interaction: {:#?}", command); let content = match command.data.name.as_str() { @@ -75,10 +76,8 @@ impl EventHandler for Handler { }; if let Err(why) = command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| message.content(content).ephemeral(true)) + .edit_original_interaction_response(&ctx.http, |response| { + response.content(content) }) .await {