From ec002229ddfa5ad3dedbccdb90ae0d9ff8a9e13a Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:05:39 -0500 Subject: [PATCH 01/19] Display modals for email and token input --- api/discord/bots/verifier.php | 98 +++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 api/discord/bots/verifier.php diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php new file mode 100644 index 00000000..98590cca --- /dev/null +++ b/api/discord/bots/verifier.php @@ -0,0 +1,98 @@ + DISCORD_ADMIN_BOT_TOKEN, + 'intents' => Intents::getDefaultIntents() +]); + +$discord->on('ready', function (Discord $discord) { + echo "Bot is ready!", PHP_EOL; + + // Listen for messages. + $discord->on(Event::MESSAGE_CREATE, function (Message $message, Discord $discord) { + echo "{$message->author->username}: {$message->content}", PHP_EOL; + }); + $discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction, Discord $discord) { + switch ($interaction->data->custom_id) { + case "get-token-button": + $interaction->showModal('Get Token', 'discord-verification-modal-email', [ + Label::new("Email", + TextInput::new("Email", 1)->setRequired(true)->setCustomId('email')->setMinLength(3)->setMaxLength(254), + "Enter a valid UNT email address." + ) + ]); + break; + case 'check-token-button': + $interaction->showModal('Verify Token', 'discord-verification-modal-token',[ + Label::new( + "Token", + TextInput::new("Token",1)->setRequired(true)->setCustomId('token'), + "Enter the token sent to your UNT email address." + ), + ]); + break; + case 'discord-verification-modal-email': + echo json_encode($interaction); + foreach($interaction->data->components as $component) { + if($component->type !== 18) continue; +// $email = $component->; + $email = $component->component->value; + } + + break; + case 'discord-verification-modal-token': + break; + } + }); + + $channel = new Channel($discord, ['id' => '948791924406489098', 'guild_id' => '889244868315074560']); + $getTokenButton = Button::new( + Button::STYLE_PRIMARY, + "get-token-button" + ); + $getTokenButton->setLabel("Get Token"); + + $checkTokenButton = Button::new( + Button::STYLE_PRIMARY, + "check-token-button" + ); + $checkTokenButton->setLabel("Verify"); + + $channel->sendMessage(MessageBuilder::new()->setContent('') + ->addEmbed( + new Embed($discord, [ + 'title' => 'Welcome to the UNT Robotics Discord server!', + 'description' => 'Before you can interact with our server, you’ll need to complete our quick verification process. This helps us keep the server safe and free from spam.' . PHP_EOL . PHP_EOL . + 'If you want to continue the verification process in Discord, read on. Otherwise, you can verify yourself on our [website](https://www.untrobotics.com/login)' . PHP_EOL . PHP_EOL . + 'To verify yourself, you\'ll need a valid UNT email address and a token we\'ll be sending to that address.' . PHP_EOL . PHP_EOL . + 'First, click the "Get Token" button below. After submitting your email address, or if you already have a token, click the "Verify" button.', + 'color' => '#059033', + ]) + ) + ->addComponent(ActionRow::new() + ->addComponent( + $getTokenButton, + )->addComponent( + $checkTokenButton, + ) + ) + ); +}); + +$discord->run(); \ No newline at end of file From 0a6c12632c2ea4e9cec883f5297b3e905b337a1f Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:31:45 -0500 Subject: [PATCH 02/19] Change to slash commands --- .../application-commands/commands.json | 30 ++ api/discord/application-commands/register.php | 16 ++ api/discord/bot.php | 17 ++ api/discord/bots/verifier.php | 266 ++++++++++++------ 4 files changed, 246 insertions(+), 83 deletions(-) create mode 100644 api/discord/application-commands/commands.json create mode 100644 api/discord/application-commands/register.php diff --git a/api/discord/application-commands/commands.json b/api/discord/application-commands/commands.json new file mode 100644 index 00000000..43a15fb9 --- /dev/null +++ b/api/discord/application-commands/commands.json @@ -0,0 +1,30 @@ +[ + { + "name": "verify-email", + "type": 1, + "description": "Verify your identity as a UNT student, faculty, or alumnus", + "options": [ + { + "name": "email", + "description": "A valid UNT email", + "type": 3, + "required": true, + "min_length": 9, + "max_length": 255 + } + ] + }, + { + "name": "verify-token", + "type": 1, + "description": "Verify you own the email provided in /verify-email", + "options": [ + { + "name": "token", + "description": "The token sent to your UNT email address", + "type": 3, + "required": true + } + ] + } +] \ No newline at end of file diff --git a/api/discord/application-commands/register.php b/api/discord/application-commands/register.php new file mode 100644 index 00000000..f71abb85 --- /dev/null +++ b/api/discord/application-commands/register.php @@ -0,0 +1,16 @@ +name . ": " . $e->getMessage(); + } +} +if(count($errs) > 0) { + error_log("Couldn't add/update the following commands to the Discord bot:\n" . implode("\n", $errs)); +} \ No newline at end of file diff --git a/api/discord/bot.php b/api/discord/bot.php index e6c03a0a..875c7e11 100644 --- a/api/discord/bot.php +++ b/api/discord/bot.php @@ -129,6 +129,23 @@ public static function get_all_users($guild_id) { return static::send_api_request("/guilds/{$guild_id}/members?limit=1000"); } + /** + * Registers an application command for the Discord bot. + * @param $command_json object JSON object with command details + * @param $application_id string The application ID of the Discord bot to register the command to + * @param $guild_id string The guild ID the command will be work on. If empty, command will be registered globally + * @return stdClass + * @throws DiscordBotException + * @see https://discord.com/developers/docs/interactions/application-commands + */ + public static function add_application_command($command_json, $application_id, $guild_id = "") { + $scope = "applications/{$application_id}"; + if($guild_id != "") { + $scope .= "/guilds/{$guild_id}"; + } + return self::send_api_request("/v10/{$scope}/commands", 'POST', "application/json", $command_json); + } + // utils public static function hasHitRateLimit($result) { return $result->status_code == 429; diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php index 98590cca..ba79161e 100644 --- a/api/discord/bots/verifier.php +++ b/api/discord/bots/verifier.php @@ -1,98 +1,198 @@ DISCORD_ADMIN_BOT_TOKEN, - 'intents' => Intents::getDefaultIntents() -]); +class DiscordWebhook +{ + private $discord; -$discord->on('ready', function (Discord $discord) { - echo "Bot is ready!", PHP_EOL; + /** + * Creates a DiscordWebhook instance. To start the webhook, call {@see DiscordWebhook::run()} + * @param string $bot_token The super secret bot token + * @throws IntentException + */ + public function __construct($bot_token = DISCORD_ADMIN_BOT_TOKEN) + { + $this->discord = new Discord([ + 'token' => $bot_token, + 'intents' => Intents::getDefaultIntents() + ]); - // Listen for messages. - $discord->on(Event::MESSAGE_CREATE, function (Message $message, Discord $discord) { - echo "{$message->author->username}: {$message->content}", PHP_EOL; - }); - $discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction, Discord $discord) { - switch ($interaction->data->custom_id) { - case "get-token-button": - $interaction->showModal('Get Token', 'discord-verification-modal-email', [ - Label::new("Email", - TextInput::new("Email", 1)->setRequired(true)->setCustomId('email')->setMinLength(3)->setMaxLength(254), - "Enter a valid UNT email address." - ) - ]); - break; - case 'check-token-button': - $interaction->showModal('Verify Token', 'discord-verification-modal-token',[ - Label::new( - "Token", - TextInput::new("Token",1)->setRequired(true)->setCustomId('token'), - "Enter the token sent to your UNT email address." - ), - ]); - break; - case 'discord-verification-modal-email': - echo json_encode($interaction); - foreach($interaction->data->components as $component) { - if($component->type !== 18) continue; -// $email = $component->; - $email = $component->component->value; - } + // runs when the bot establishes a connection with Discord + $this->discord->on('ready', function (Discord $discord) { +// echo "Bot is ready!", PHP_EOL; + + $discord->on(Event::INTERACTION_CREATE, DiscordWebhook::AcknowledgeInteraction(...)); + }); + } + /** + * Starts the webhook. + */ + public function run(){ + $this->discord->run(); + } + + /** + * Handler for {@see Event::INTERACTION_CREATE} events. + * @param Interaction $interaction + * @param Discord $discord + * @return void + */ + private static function AcknowledgeInteraction(Interaction $interaction, Discord $discord) { + echo "Received interaction: " . PHP_EOL . json_encode($interaction) . PHP_EOL; + if(is_null($interaction->data)){ + echo "Unknown interaction" . PHP_EOL; + return; + } + switch ($interaction->data->name) { + case "verify-email": + /** @noinspection PhpUndefinedFieldInspection */ + self::ValidateEmail($interaction); break; - case 'discord-verification-modal-token': + case "verify-token": + self::ValidateToken($interaction); break; + default: + echo $interaction->data->name . PHP_EOL; } - }); + } - $channel = new Channel($discord, ['id' => '948791924406489098', 'guild_id' => '889244868315074560']); - $getTokenButton = Button::new( - Button::STYLE_PRIMARY, - "get-token-button" - ); - $getTokenButton->setLabel("Get Token"); + /** + * Validate a /verify-email interaction + * @param Interaction $interaction + * @return void + */ + private static function ValidateEmail(Interaction $interaction){ + $email = $interaction->data->options["email"]->value; + if(!self::IsValidEmail($email)){ + $interaction->respondWithMessage( + MessageBuilder::new()->setContent( + "Invalid email address provided. Make sure the email is a valid UNT email. If you're having issues verifying yourself, contact an officer.". PHP_EOL . PHP_EOL . "Email: ``{$email}``" + ), + true + ); + return; + } + $interaction->acknowledgeWithResponse(true); + //todo: send email + $email_sent = true; + if($email_sent){ + $response = "Email sent to {$email}. Use ``/verify-token `` to continue the verification process. Tokens are valid for 7 days." . PHP_EOL . PHP_EOL . "If you can't find the email, check your spam folder."; + } else { + $response = "Error sending the email to {$email}: {$email_sent}." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; + error_log($email_sent); + } + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent($response) + ); + } - $checkTokenButton = Button::new( - Button::STYLE_PRIMARY, - "check-token-button" - ); - $checkTokenButton->setLabel("Verify"); + /** + * Validate a /verify-token interaction + * @param Interaction $interaction + * @return void + */ + private static function ValidateToken(Interaction $interaction){ + $token = $interaction->data->options["token"]->value; + if(!self::IsValidToken($token)){ + $interaction->respondWithMessage( + MessageBuilder::new()->setContent( + "Verification failed." + ), + true + ); + return; + } + //todo: check db + global $db; + + $q = $db->query("SELECT * FROM "); + if($q === false){ + $interaction->respondWithMessage( + MessageBuilder::new()->setContent( + "Verification failed due to internal server error. Contact an officer if this issue persists.." + ), + true + ); +// error_log("Failed to fetch token from user verification table: {$db->error}"); + return; + } /*elseif($q && $q->num_rows > 0) { + $r = $q->fetch_array(MYSQLI_ASSOC); + if($token === $r['token']){ + $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $token){ + $interaction->member->addRole($verified_role_id)->then(function () use ($interaction){ + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent( + "You've successfully verified your email address. Welcome to the server." + ), + ); + }, + function ($reason) use ($interaction, $token){ + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent( + "There was an issue trying to adding the verified role to you. Contact an officer with your token to get your roles updated." + ), + ); + error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; token: {$token}): $reason"); + } + ); + }); + return; + } + }*/ + else{ + if($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ + // user already has role + $interaction->respondWithMessage( + MessageBuilder::new()->setContent("You already the verified role.") + ); + return; + } + $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $token) { + $interaction->member->addRole(DISCORD_VERIFIED_ROLE_ID)->then(function () use ($interaction) { + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent( + "You've successfully verified your email address. Welcome to the server." + ), + ); + }, + function ($reason) use ($interaction, $token) { + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent( + "There was an issue trying to adding the verified role to you. Contact an officer with your token to get your roles updated." + ), + ); + error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; token: {$token}): $reason"); + } + ); + }); + } + } - $channel->sendMessage(MessageBuilder::new()->setContent('') - ->addEmbed( - new Embed($discord, [ - 'title' => 'Welcome to the UNT Robotics Discord server!', - 'description' => 'Before you can interact with our server, you’ll need to complete our quick verification process. This helps us keep the server safe and free from spam.' . PHP_EOL . PHP_EOL . - 'If you want to continue the verification process in Discord, read on. Otherwise, you can verify yourself on our [website](https://www.untrobotics.com/login)' . PHP_EOL . PHP_EOL . - 'To verify yourself, you\'ll need a valid UNT email address and a token we\'ll be sending to that address.' . PHP_EOL . PHP_EOL . - 'First, click the "Get Token" button below. After submitting your email address, or if you already have a token, click the "Verify" button.', - 'color' => '#059033', - ]) - ) - ->addComponent(ActionRow::new() - ->addComponent( - $getTokenButton, - )->addComponent( - $checkTokenButton, - ) - ) - ); -}); + /** + * Verify that an email address is a UNT email + * @param string $email The email to validate + * @return bool True if the email is a UNT email. False otherwise + */ + private static function IsValidEmail(string $email): bool{ + // regex for the domain name + $valid_domains = '/@(?:my\.)?unt\.edu$/i'; + return filter_var($email, FILTER_VALIDATE_EMAIL) && preg_match($valid_domains, $email) === 1; + } -$discord->run(); \ No newline at end of file + /** + * Verify that a token is in the accepted format (i.e., hexadecimal) + * @param string $token The token to validate + * @return bool True if the token is in the accepted format. False otherwise + */ + private static function IsValidToken(string $token): bool{ + return ctype_xdigit($token); + } +} \ No newline at end of file From 060031bdeb1cc173b47106e2bbd1a9b95c7a746e Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Sun, 7 Sep 2025 10:56:02 -0500 Subject: [PATCH 03/19] Added some comments --- api/discord/application-commands/register.php | 5 + api/discord/bot.php | 1 + api/discord/bots/verifier.php | 133 ++++++++++++++---- 3 files changed, 114 insertions(+), 25 deletions(-) diff --git a/api/discord/application-commands/register.php b/api/discord/application-commands/register.php index f71abb85..c20efa3d 100644 --- a/api/discord/application-commands/register.php +++ b/api/discord/application-commands/register.php @@ -2,15 +2,20 @@ require_once(__DIR__ . "/../bots/admin.php"); require_once(__DIR__ . "/../../../template/config.php"); +// commands are stored in a JSON format. See https://discord.com/developers/docs/interactions/application-commands#application-command-object for object structure $commands = json_decode(file_get_contents(__DIR__ . "/commands.json"), false); $errs = []; +// have to register each command one-by-one. You can only bulk update commands, not register (I think) foreach ($commands as $command) { + // try to add the app command and log the error to the error array if it fails try { AdminBot::add_application_command($command, DISCORD_APP_CLIENT_ID, DISCORD_GUILD_ID); } catch (DiscordBotException $e) { $errs[] = $command->name . ": " . $e->getMessage(); } } + +// log errors if(count($errs) > 0) { error_log("Couldn't add/update the following commands to the Discord bot:\n" . implode("\n", $errs)); } \ No newline at end of file diff --git a/api/discord/bot.php b/api/discord/bot.php index 875c7e11..468cc5c2 100644 --- a/api/discord/bot.php +++ b/api/discord/bot.php @@ -140,6 +140,7 @@ public static function get_all_users($guild_id) { */ public static function add_application_command($command_json, $application_id, $guild_id = "") { $scope = "applications/{$application_id}"; + // if $guild_id is blank, then the scope is global (available in all servers [the bot is in] and DMs if($guild_id != "") { $scope .= "/guilds/{$guild_id}"; } diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php index ba79161e..4c415e7e 100644 --- a/api/discord/bots/verifier.php +++ b/api/discord/bots/verifier.php @@ -19,6 +19,7 @@ class DiscordWebhook */ public function __construct($bot_token = DISCORD_ADMIN_BOT_TOKEN) { + // bot doesn't need special intents $this->discord = new Discord([ 'token' => $bot_token, 'intents' => Intents::getDefaultIntents() @@ -26,8 +27,7 @@ public function __construct($bot_token = DISCORD_ADMIN_BOT_TOKEN) // runs when the bot establishes a connection with Discord $this->discord->on('ready', function (Discord $discord) { -// echo "Bot is ready!", PHP_EOL; - + // adds event listener for when an Interaction is started. Interactions are things like slash commands or clicking a button on the bot's message $discord->on(Event::INTERACTION_CREATE, DiscordWebhook::AcknowledgeInteraction(...)); }); } @@ -46,21 +46,21 @@ public function run(){ * @return void */ private static function AcknowledgeInteraction(Interaction $interaction, Discord $discord) { - echo "Received interaction: " . PHP_EOL . json_encode($interaction) . PHP_EOL; + // ignore interactions which don't provide data if(is_null($interaction->data)){ - echo "Unknown interaction" . PHP_EOL; + error_log("Discord verification bot received an unknown interaction initiated by " . $interaction->user->id . " in guild " . $interaction->guild->id . " and channel " . $interaction->channel->id . "."); return; } + // handle interactions based on interaction names switch ($interaction->data->name) { case "verify-email": - /** @noinspection PhpUndefinedFieldInspection */ self::ValidateEmail($interaction); break; case "verify-token": self::ValidateToken($interaction); break; default: - echo $interaction->data->name . PHP_EOL; + error_log("Discord verification bot received an unknown interaction with name " . $interaction->data->name); } } @@ -70,7 +70,9 @@ private static function AcknowledgeInteraction(Interaction $interaction, Discord * @return void */ private static function ValidateEmail(Interaction $interaction){ + // get email passed to the bot $email = $interaction->data->options["email"]->value; + // if email isn't a unt email, provide error message to user and end interaction if(!self::IsValidEmail($email)){ $interaction->respondWithMessage( MessageBuilder::new()->setContent( @@ -80,17 +82,89 @@ private static function ValidateEmail(Interaction $interaction){ ); return; } - $interaction->acknowledgeWithResponse(true); - //todo: send email - $email_sent = true; - if($email_sent){ - $response = "Email sent to {$email}. Use ``/verify-token `` to continue the verification process. Tokens are valid for 7 days." . PHP_EOL . PHP_EOL . "If you can't find the email, check your spam folder."; - } else { - $response = "Error sending the email to {$email}: {$email_sent}." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; - error_log($email_sent); - } - $interaction->updateOriginalResponse( - MessageBuilder::new()->setContent($response) + + // user sees an ephemeral, loading message from the bot + $interaction->acknowledgeWithResponse(true)->then( + function () use($interaction, $email) { + + //generate token + $token = bin2hex(openssl_random_pseudo_bytes(3)); + + // add token to discrd_verification_tokens table + global $db; + //todo update db + $q = $db->query( + "INSERT INTO discord_verification_tokens (token, expires_on, discord_id, user_id, unt_email)" + ); + + // if token couldn't be added to the table, respond with an error message, and log db error for devs to see + if (!$q) { + $response = "Could not generate verification token." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; + error_log("Failed to update DB with verification token for <@" . $interaction->user->id . "> (email: {$email}): " . $db->error); + } else { + // Send generic welcome email + $email_send_status = email( + $email, + "UNT Robotics Discord verification token", + + "
" . + '' . + + '
' . + + '
' . + "

Hey there!

" . + "

Welcome to the team!!

" . + "

Thank you for joining the UNT Robotics Discord server!" . + "

Your verification token is:

" . + "
" . + "
" . + "

{$token}

" . + '
' . + + '
' . + + "

" . + + "

If you need any assistance, please reach out to hello@untrobotics.com.

" . + + '
' . + + '
' . + "

All the best,

" . + "

UNT Robotics Leadership

" . + '
' . + + "
", + + "hello@untrobotics.com", + null, + [ + [ + 'content' => base64_encode(file_get_contents(BASE . '/images/unt-robotics-email-header.jpg')), + 'type' => 'image/jpeg', + 'filename' => 'unt-robotics-email-header.jpg', + 'disposition' => 'inline', + 'content_id' => 'untrobotics-email-header' + ] + ] + ); + + // if the email was successfully sent, respond with a success message. + if ($email_send_status) { + $response = "Email sent to {$email}. Use ``/verify-token `` to continue the verification process. Tokens are valid for 7 days." . PHP_EOL . PHP_EOL . "If you can't find the email, check your spam folder."; + } else { + // if email wasn't sent, respond with an error message and remove the token from the db. + $response = "Could not send an email to {$email}." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; + $q = "DELETE FROM discord_verification_tokens WHERE token = '{$token}' AND discord_id = '{$interaction->user->id}'"; + error_log("Failed to send verification email with a token to {$email}."); + } + } + // update loading message with proper response + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent($response) + ); + } ); } @@ -100,7 +174,11 @@ private static function ValidateEmail(Interaction $interaction){ * @return void */ private static function ValidateToken(Interaction $interaction){ - $token = $interaction->data->options["token"]->value; + global $db; + // get token passed to the bot + $token = $db->real_escape_string($interaction->data->options["token"]->value); + $user_id = $interaction->user->id; + // if token isn't valid, tell user verification failed and end interaction if(!self::IsValidToken($token)){ $interaction->respondWithMessage( MessageBuilder::new()->setContent( @@ -111,9 +189,8 @@ private static function ValidateToken(Interaction $interaction){ return; } //todo: check db - global $db; - - $q = $db->query("SELECT * FROM "); + $q = $db->query("SELECT * FROM discord_verification_tokens WHERE token = '{$token}' AND discord_id = '{$interaction->user->id}'"); + // if query fails, respond with an error message, log the db error, and end the interaction if($q === false){ $interaction->respondWithMessage( MessageBuilder::new()->setContent( @@ -121,7 +198,7 @@ private static function ValidateToken(Interaction $interaction){ ), true ); -// error_log("Failed to fetch token from user verification table: {$db->error}"); + error_log("Failed to fetch token ({$token}) from discord verification table for user {$user_id}: {$db->error}"); return; } /*elseif($q && $q->num_rows > 0) { $r = $q->fetch_array(MYSQLI_ASSOC); @@ -147,22 +224,28 @@ function ($reason) use ($interaction, $token){ return; } }*/ - else{ + else { + // if user is already verified (has the verified role), tell them they already have the role and end the interaction if($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ - // user already has role $interaction->respondWithMessage( MessageBuilder::new()->setContent("You already the verified role.") ); return; } + + // send a loading message to user, then try to add the verified role to the user $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $token) { - $interaction->member->addRole(DISCORD_VERIFIED_ROLE_ID)->then(function () use ($interaction) { + $interaction->member->addRole(DISCORD_VERIFIED_ROLE_ID)->then( + + // if the role adding succeeded, update loading message with a success message + function () use ($interaction) { $interaction->updateOriginalResponse( MessageBuilder::new()->setContent( "You've successfully verified your email address. Welcome to the server." ), ); }, + // if the role adding failed, update loading message with an error message and log the full error function ($reason) use ($interaction, $token) { $interaction->updateOriginalResponse( MessageBuilder::new()->setContent( From 5b920b69bea695f66fce454a2b0c52881730ec46 Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Mon, 8 Sep 2025 18:41:21 -0500 Subject: [PATCH 04/19] create table to hold discord verification tokens --- sql/discord_verification_tokens.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 sql/discord_verification_tokens.sql diff --git a/sql/discord_verification_tokens.sql b/sql/discord_verification_tokens.sql new file mode 100644 index 00000000..2b55387d --- /dev/null +++ b/sql/discord_verification_tokens.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS `discord_verification_tokens`; +CREATE TABLE `discord_verification_tokens`( + `id` int(11) NOT NULL AUTO_INCREMENT, + `token` varchar(6) NOT NULL, + `expires_on` timestamp NOT NULL, + `discord_id` varchar(19) NOT NULL, + `user_id` int(11) NOT NULL, + `unt_email` varchar(255) NOT NULL, + PRIMARY KEY(id), + FOREIGN KEY (user_id) REFERENCES users(id) +) AUTO_INCREMENT=8 DEFAULT CHARSET=latin1; \ No newline at end of file From 894c0fb70beb2a969eba9672dced8b47cabdf6b2 Mon Sep 17 00:00:00 2001 From: Kenneth-W-Chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Mon, 8 Sep 2025 20:14:48 -0500 Subject: [PATCH 05/19] URW-167 Add DB interaction to verification bot Update discord_id field to use same type as in users table Update sample.config with DISCORD_VERIFIED_ROLE_ID constant --- api/discord/bots/verifier.php | 251 +++++++++++++++++----------- sql/discord_verification_tokens.sql | 4 +- template/sample.config.php | 1 + 3 files changed, 159 insertions(+), 97 deletions(-) diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php index 4c415e7e..49028d4a 100644 --- a/api/discord/bots/verifier.php +++ b/api/discord/bots/verifier.php @@ -18,21 +18,21 @@ class DiscordWebhook * @throws IntentException */ public function __construct($bot_token = DISCORD_ADMIN_BOT_TOKEN) - { - // bot doesn't need special intents - $this->discord = new Discord([ - 'token' => $bot_token, - 'intents' => Intents::getDefaultIntents() - ]); + { + // bot doesn't need special intents + $this->discord = new Discord([ + 'token' => $bot_token, + 'intents' => Intents::getDefaultIntents() + ]); - // runs when the bot establishes a connection with Discord - $this->discord->on('ready', function (Discord $discord) { - // adds event listener for when an Interaction is started. Interactions are things like slash commands or clicking a button on the bot's message - $discord->on(Event::INTERACTION_CREATE, DiscordWebhook::AcknowledgeInteraction(...)); - }); - } + // runs when the bot establishes a connection with Discord + $this->discord->on('ready', function (Discord $discord) { + // adds event listener for when an Interaction is started. Interactions are things like slash commands or clicking a button on the bot's message + $discord->on(Event::INTERACTION_CREATE, DiscordWebhook::AcknowledgeInteraction(...)); + }); + } - /** + /** * Starts the webhook. */ public function run(){ @@ -70,9 +70,17 @@ private static function AcknowledgeInteraction(Interaction $interaction, Discord * @return void */ private static function ValidateEmail(Interaction $interaction){ - // get email passed to the bot + // don't do the process if the user has the role already + if($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ + $interaction->respondWithMessage( + MessageBuilder::new()->setContent("You already have the verified role.") + ); + return; + } + + // get email passed to the bot $email = $interaction->data->options["email"]->value; - // if email isn't a unt email, provide error message to user and end interaction + // if email isn't a UNT email, provide error message to user and end interaction if(!self::IsValidEmail($email)){ $interaction->respondWithMessage( MessageBuilder::new()->setContent( @@ -86,21 +94,58 @@ private static function ValidateEmail(Interaction $interaction){ // user sees an ephemeral, loading message from the bot $interaction->acknowledgeWithResponse(true)->then( function () use($interaction, $email) { + global $db; + $escaped_email = $db->real_escape_string($email); + $discord_id = $interaction->user->id; - //generate token + // check DB to see if the user and email have already been linked together + //todo should this be a discord_id check only? + $q = $db->query(" + SELECT + email, discord_id + FROM + users + WHERE + email = '{$escaped_email}' + AND + discord_id = {$discord_id} + "); + if($q === false){ + error_log("Failed to update DB with verification token for <@" . $discord_id . "> (email: {$email}): " . $db->error); + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent("Could not generate verification token." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer.") + ); + return; + } + if($q->num_rows > 0) { + self::AssignVerfiedRole($interaction, null); + } + //generate token $token = bin2hex(openssl_random_pseudo_bytes(3)); - // add token to discrd_verification_tokens table - global $db; - //todo update db - $q = $db->query( - "INSERT INTO discord_verification_tokens (token, expires_on, discord_id, user_id, unt_email)" + // add token to discrd_verification_tokens table + //inserts the token with the email and discord id + // expiration date is 7 days from insertion + $q = $db->query( + "INSERT INTO + discord_verification_tokens ( + token, + expires_on, + discord_id, + unt_email + ) + VALUES ( + '{$token}', + CURRENT_TIMESTAMP() + INTERVAL 7 DAY, + $discord_id, + '{$escaped_email}' + )" ); - // if token couldn't be added to the table, respond with an error message, and log db error for devs to see - if (!$q) { + // if token couldn't be added to the table, respond with an error message, and log db error for devs to see + if (!$q) { $response = "Could not generate verification token." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; - error_log("Failed to update DB with verification token for <@" . $interaction->user->id . "> (email: {$email}): " . $db->error); + error_log("Failed to update DB with verification token for <@" . $discord_id . "> (email: {$email}): " . $db->error); } else { // Send generic welcome email $email_send_status = email( @@ -156,10 +201,14 @@ function () use($interaction, $email) { } else { // if email wasn't sent, respond with an error message and remove the token from the db. $response = "Could not send an email to {$email}." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; - $q = "DELETE FROM discord_verification_tokens WHERE token = '{$token}' AND discord_id = '{$interaction->user->id}'"; + $q = "DELETE FROM discord_verification_tokens WHERE token = '{$token}' AND discord_id = '{$discord_id}'"; + if($q === false) { + error_log("Failed to send verification email with a token to {$email}. Also failed to remove the new token entry from the tokens table."); + } error_log("Failed to send verification email with a token to {$email}."); } } + // update loading message with proper response $interaction->updateOriginalResponse( MessageBuilder::new()->setContent($response) @@ -174,9 +223,18 @@ function () use($interaction, $email) { * @return void */ private static function ValidateToken(Interaction $interaction){ - global $db; + // if user is already verified (has the verified role), tell them they already have the role and end the interaction + if($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ + $interaction->respondWithMessage( + MessageBuilder::new()->setContent("You already have the verified role.") + ); + return; + } + + global $db; // get token passed to the bot - $token = $db->real_escape_string($interaction->data->options["token"]->value); + // $token sanitized for db since it won't be used elsewhere + $token = $interaction->data->options["token"]->value; $user_id = $interaction->user->id; // if token isn't valid, tell user verification failed and end interaction if(!self::IsValidToken($token)){ @@ -188,85 +246,88 @@ private static function ValidateToken(Interaction $interaction){ ); return; } - //todo: check db + //todo can we check for discord_id in users table? + // fetch token from db where the user's discord id and token provided match $q = $db->query("SELECT * FROM discord_verification_tokens WHERE token = '{$token}' AND discord_id = '{$interaction->user->id}'"); // if query fails, respond with an error message, log the db error, and end the interaction - if($q === false){ - $interaction->respondWithMessage( - MessageBuilder::new()->setContent( - "Verification failed due to internal server error. Contact an officer if this issue persists.." - ), - true - ); - error_log("Failed to fetch token ({$token}) from discord verification table for user {$user_id}: {$db->error}"); - return; - } /*elseif($q && $q->num_rows > 0) { - $r = $q->fetch_array(MYSQLI_ASSOC); - if($token === $r['token']){ - $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $token){ - $interaction->member->addRole($verified_role_id)->then(function () use ($interaction){ - $interaction->updateOriginalResponse( - MessageBuilder::new()->setContent( - "You've successfully verified your email address. Welcome to the server." - ), - ); - }, - function ($reason) use ($interaction, $token){ - $interaction->updateOriginalResponse( - MessageBuilder::new()->setContent( - "There was an issue trying to adding the verified role to you. Contact an officer with your token to get your roles updated." - ), - ); - error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; token: {$token}): $reason"); - } - ); - }); - return; - } - }*/ - else { - // if user is already verified (has the verified role), tell them they already have the role and end the interaction - if($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ - $interaction->respondWithMessage( - MessageBuilder::new()->setContent("You already the verified role.") - ); - return; - } + if ($q === false) { + $interaction->respondWithMessage( + MessageBuilder::new()->setContent( + "Verification failed due to internal server error. Contact an officer if this issue persists.." + ), + true + ); + error_log("Failed to fetch token ({$token}) from discord verification table for user {$user_id}: {$db->error}"); + return; + } + // if no entries match token-discord_id combo then fail verification + if ($q->num_rows < 1) { + $interaction->respondWithMessage( + MessageBuilder::new()->setContent( + "Verification failed." + ), + true + ); + return; + } - // send a loading message to user, then try to add the verified role to the user - $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $token) { - $interaction->member->addRole(DISCORD_VERIFIED_ROLE_ID)->then( + $r = $q->fetch_array(MYSQLI_ASSOC); + // user_id isn't null if the token was linked to a user + if ($r['user_id'] != null) { + $interaction->respondWithMessage( + MessageBuilder::new()->setContent( + "Verification failed." + ), + true + ); + return; + } - // if the role adding succeeded, update loading message with a success message - function () use ($interaction) { - $interaction->updateOriginalResponse( - MessageBuilder::new()->setContent( - "You've successfully verified your email address. Welcome to the server." - ), - ); - }, - // if the role adding failed, update loading message with an error message and log the full error - function ($reason) use ($interaction, $token) { - $interaction->updateOriginalResponse( - MessageBuilder::new()->setContent( - "There was an issue trying to adding the verified role to you. Contact an officer with your token to get your roles updated." - ), - ); - error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; token: {$token}): $reason"); - } - ); - }); - } - } + self::AssignVerfiedRole($interaction, $token); + } + + /** + * Acknowledges the interaction with a loading message, then tries to add the verified role to the user. + * Logs an error if the role update failed. + * @param Interaction $interaction + * @param string|null $token + * @return void + */ + public static function AssignVerfiedRole(Interaction $interaction, mixed $token): void + { + $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $token) { + $interaction->member->addRole(DISCORD_VERIFIED_ROLE_ID)->then(function () use ($interaction) { + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent( + "You've successfully verified your email address. Welcome to the server." + ), + ); + }, + function ($reason) use ($interaction, $token) { + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent( + "There was an issue trying to adding the verified role to you. Contact an officer with your token to get your roles updated." + ), + ); + if($token !== null) { + error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; token: {$token}): $reason"); + } else { + error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; no token required): $reason"); + } + + } + ); + }); + } /** - * Verify that an email address is a UNT email + * Verify that an email address is a my.unt email * @param string $email The email to validate * @return bool True if the email is a UNT email. False otherwise */ private static function IsValidEmail(string $email): bool{ // regex for the domain name - $valid_domains = '/@(?:my\.)?unt\.edu$/i'; + $valid_domains = '/@my\.unt\.edu$/i'; return filter_var($email, FILTER_VALIDATE_EMAIL) && preg_match($valid_domains, $email) === 1; } diff --git a/sql/discord_verification_tokens.sql b/sql/discord_verification_tokens.sql index 2b55387d..aade2ed2 100644 --- a/sql/discord_verification_tokens.sql +++ b/sql/discord_verification_tokens.sql @@ -3,8 +3,8 @@ CREATE TABLE `discord_verification_tokens`( `id` int(11) NOT NULL AUTO_INCREMENT, `token` varchar(6) NOT NULL, `expires_on` timestamp NOT NULL, - `discord_id` varchar(19) NOT NULL, - `user_id` int(11) NOT NULL, + `discord_id` bigint(20) NOT NULL, + `user_id` int(11), `unt_email` varchar(255) NOT NULL, PRIMARY KEY(id), FOREIGN KEY (user_id) REFERENCES users(id) diff --git a/template/sample.config.php b/template/sample.config.php index 67970de4..c021e8cf 100644 --- a/template/sample.config.php +++ b/template/sample.config.php @@ -86,6 +86,7 @@ '755952946566660206' // Web/Ops Team ) ); +define('DISCORD_VERIFIED_ROLE_ID', ''); define('PAYPAL_PDT_ID_TOKEN', ''); define('PAYPAL_SANDBOX_PDT_ID_TOKEN', ''); From 918cec911bd6431156ac7f482f2e067ad98478b5 Mon Sep 17 00:00:00 2001 From: Kenneth Chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Tue, 9 Sep 2025 07:26:21 -0500 Subject: [PATCH 06/19] URW-167 Add verified role ID value to sample config file --- template/sample.config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/sample.config.php b/template/sample.config.php index c021e8cf..bf51990b 100644 --- a/template/sample.config.php +++ b/template/sample.config.php @@ -86,7 +86,7 @@ '755952946566660206' // Web/Ops Team ) ); -define('DISCORD_VERIFIED_ROLE_ID', ''); +define('DISCORD_VERIFIED_ROLE_ID', '1414807269580734527'); define('PAYPAL_PDT_ID_TOKEN', ''); define('PAYPAL_SANDBOX_PDT_ID_TOKEN', ''); From 8c46454f66d10d30d459c81c8f8dc5846fba4767 Mon Sep 17 00:00:00 2001 From: Kenneth Chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Wed, 10 Sep 2025 22:23:20 -0500 Subject: [PATCH 07/19] Add unt_email column to users table --- sql/migrations/URW-167-unt-email-field.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sql/migrations/URW-167-unt-email-field.sql diff --git a/sql/migrations/URW-167-unt-email-field.sql b/sql/migrations/URW-167-unt-email-field.sql new file mode 100644 index 00000000..f79a9c0b --- /dev/null +++ b/sql/migrations/URW-167-unt-email-field.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN unt_email varchar(255); From 4ec783839600c1705025610f945b40e6b340b0bc Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:17:23 -0500 Subject: [PATCH 08/19] Update verification bot with changes to users table Reformatting and reworded some comments --- api/discord/bots/verifier.php | 243 +++++++++++++++++++++------- sql/discord_verification_tokens.sql | 2 +- 2 files changed, 188 insertions(+), 57 deletions(-) diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php index 49028d4a..62a67233 100644 --- a/api/discord/bots/verifier.php +++ b/api/discord/bots/verifier.php @@ -8,12 +8,13 @@ use Discord\WebSockets\Event; use Discord\WebSockets\Intents; -class DiscordWebhook +class VerificationBot { private $discord; + private const int TOKEN_GENERATION_RATE_LIMIT = 300; /** - * Creates a DiscordWebhook instance. To start the webhook, call {@see DiscordWebhook::run()} + * Creates a VerificationBot instance. To start the webhook, call {@see VerificationBot::run()} * @param string $bot_token The super secret bot token * @throws IntentException */ @@ -28,7 +29,7 @@ public function __construct($bot_token = DISCORD_ADMIN_BOT_TOKEN) // runs when the bot establishes a connection with Discord $this->discord->on('ready', function (Discord $discord) { // adds event listener for when an Interaction is started. Interactions are things like slash commands or clicking a button on the bot's message - $discord->on(Event::INTERACTION_CREATE, DiscordWebhook::AcknowledgeInteraction(...)); + $discord->on(Event::INTERACTION_CREATE, VerificationBot::AcknowledgeInteraction(...)); }); } @@ -47,7 +48,7 @@ public function run(){ */ private static function AcknowledgeInteraction(Interaction $interaction, Discord $discord) { // ignore interactions which don't provide data - if(is_null($interaction->data)){ + if (is_null($interaction->data)){ error_log("Discord verification bot received an unknown interaction initiated by " . $interaction->user->id . " in guild " . $interaction->guild->id . " and channel " . $interaction->channel->id . "."); return; } @@ -69,9 +70,9 @@ private static function AcknowledgeInteraction(Interaction $interaction, Discord * @param Interaction $interaction * @return void */ - private static function ValidateEmail(Interaction $interaction){ + private static function ValidateEmail(Interaction $interaction) { // don't do the process if the user has the role already - if($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ + if ($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ $interaction->respondWithMessage( MessageBuilder::new()->setContent("You already have the verified role.") ); @@ -81,7 +82,7 @@ private static function ValidateEmail(Interaction $interaction){ // get email passed to the bot $email = $interaction->data->options["email"]->value; // if email isn't a UNT email, provide error message to user and end interaction - if(!self::IsValidEmail($email)){ + if (!self::IsValidEmail($email)){ $interaction->respondWithMessage( MessageBuilder::new()->setContent( "Invalid email address provided. Make sure the email is a valid UNT email. If you're having issues verifying yourself, contact an officer.". PHP_EOL . PHP_EOL . "Email: ``{$email}``" @@ -98,56 +99,98 @@ function () use($interaction, $email) { $escaped_email = $db->real_escape_string($email); $discord_id = $interaction->user->id; - // check DB to see if the user and email have already been linked together - //todo should this be a discord_id check only? - $q = $db->query(" - SELECT - email, discord_id - FROM - users - WHERE - email = '{$escaped_email}' - AND - discord_id = {$discord_id} + // check DB to see if the user already has a UNT email verified + $q = $db->query("SELECT + id + FROM + users + WHERE + unt_email IS NOT NULL + AND + discord_id = {$discord_id} "); - if($q === false){ - error_log("Failed to update DB with verification token for <@" . $discord_id . "> (email: {$email}): " . $db->error); + // db error + if ($q === false) { + error_log("Failed to check verification status for <@{$discord_id}> (email: {$email}) when generating token: {$db->error}"); $interaction->updateOriginalResponse( MessageBuilder::new()->setContent("Could not generate verification token." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer.") ); return; } - if($q->num_rows > 0) { + + // if the user has been verified already, try to add the role instead of generating a token + if ($q->num_rows > 0) { self::AssignVerfiedRole($interaction, null); + return; } + + // check for an existing token + $q = $db->query("SELECT + id, created_on + FROM + discord_verification_tokens + WHERE + discord_id = {$discord_id} + ORDER BY + created_on DESC LIMIT 1"); + // db error + if ($q === false) { + error_log("Failed to check for existing tokens for <@{$discord_id}>: {$db->error}"); + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent("Could not generate verification token." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer.") + ); + return; + } + // token exists + if ($q->num_rows > 0) { + // check for ratelimit + $r = $q->fetch_assoc(); + /** @noinspection PhpUnhandledExceptionInspection */ + + // if ratelimit reached, stop interaction and tell user to wait before generating a new token + if (new DateTime()->sub(new DateInterval('PT' . self::TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent("Please wait a few minutes before requesting a new token.") + ); + return; + } + + // delete the token from the table so we can generate a new one + $q = $db->query("DELETE FROM discord_verification_tokens WHERE id = {$r['id']}"); + // db error + if ($q === false) { + error_log("Failed to delete verification token for <@" . $discord_id . "> (email: {$email}): {$db->error}"); + $interaction->updateOriginalResponse( + MessageBuilder::new()->setContent("Could not generate verification token." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer.") + ); + return; + } + } + //generate token $token = bin2hex(openssl_random_pseudo_bytes(3)); // add token to discrd_verification_tokens table - //inserts the token with the email and discord id - // expiration date is 7 days from insertion - $q = $db->query( - "INSERT INTO - discord_verification_tokens ( - token, - expires_on, - discord_id, - unt_email - ) - VALUES ( - '{$token}', - CURRENT_TIMESTAMP() + INTERVAL 7 DAY, - $discord_id, - '{$escaped_email}' - )" + $q = $db->query("INSERT INTO discord_verification_tokens ( + token, + created_on, + discord_id, + unt_email + ) + VALUES ( + '{$token}', + CURRENT_TIMESTAMP(), + $discord_id, + '{$escaped_email}' + )" ); - // if token couldn't be added to the table, respond with an error message, and log db error for devs to see + // db error if (!$q) { $response = "Could not generate verification token." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; error_log("Failed to update DB with verification token for <@" . $discord_id . "> (email: {$email}): " . $db->error); } else { - // Send generic welcome email + // Email token $email_send_status = email( $email, "UNT Robotics Discord verification token", @@ -202,14 +245,14 @@ function () use($interaction, $email) { // if email wasn't sent, respond with an error message and remove the token from the db. $response = "Could not send an email to {$email}." . PHP_EOL . PHP_EOL . "If this issue persists, contact an officer."; $q = "DELETE FROM discord_verification_tokens WHERE token = '{$token}' AND discord_id = '{$discord_id}'"; - if($q === false) { + if ($q === false) { error_log("Failed to send verification email with a token to {$email}. Also failed to remove the new token entry from the tokens table."); } error_log("Failed to send verification email with a token to {$email}."); } } - // update loading message with proper response + // update loading message with proper response... any updated response before this is an error message $interaction->updateOriginalResponse( MessageBuilder::new()->setContent($response) ); @@ -222,9 +265,9 @@ function () use($interaction, $email) { * @param Interaction $interaction * @return void */ - private static function ValidateToken(Interaction $interaction){ + private static function ValidateToken(Interaction $interaction) { // if user is already verified (has the verified role), tell them they already have the role and end the interaction - if($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ + if ($interaction->member->roles->has(DISCORD_VERIFIED_ROLE_ID)){ $interaction->respondWithMessage( MessageBuilder::new()->setContent("You already have the verified role.") ); @@ -235,9 +278,9 @@ private static function ValidateToken(Interaction $interaction){ // get token passed to the bot // $token sanitized for db since it won't be used elsewhere $token = $interaction->data->options["token"]->value; - $user_id = $interaction->user->id; + $discord_id = $interaction->user->id; // if token isn't valid, tell user verification failed and end interaction - if(!self::IsValidToken($token)){ + if (!self::IsValidToken($token)){ $interaction->respondWithMessage( MessageBuilder::new()->setContent( "Verification failed." @@ -246,18 +289,26 @@ private static function ValidateToken(Interaction $interaction){ ); return; } - //todo can we check for discord_id in users table? // fetch token from db where the user's discord id and token provided match - $q = $db->query("SELECT * FROM discord_verification_tokens WHERE token = '{$token}' AND discord_id = '{$interaction->user->id}'"); + $q = $db->query("SELECT + id, user_id, unt_email + FROM + discord_verification_tokens + WHERE + token = '{$token}' + AND + discord_id = {$discord_id} + AND + created_on + INTERVAL 7 DAY > CURRENT_TIMESTAMP()"); // if query fails, respond with an error message, log the db error, and end the interaction if ($q === false) { $interaction->respondWithMessage( MessageBuilder::new()->setContent( - "Verification failed due to internal server error. Contact an officer if this issue persists.." + "Verification failed due to internal server error. Contact an officer if this issue persists." ), true ); - error_log("Failed to fetch token ({$token}) from discord verification table for user {$user_id}: {$db->error}"); + error_log("Failed to fetch token ({$token}) from discord verification table for user {$discord_id}: {$db->error}"); return; } // if no entries match token-discord_id combo then fail verification @@ -272,7 +323,7 @@ private static function ValidateToken(Interaction $interaction){ } $r = $q->fetch_array(MYSQLI_ASSOC); - // user_id isn't null if the token was linked to a user + // user_id isn't null if the token was linked to a user with this discord ID if ($r['user_id'] != null) { $interaction->respondWithMessage( MessageBuilder::new()->setContent( @@ -284,7 +335,8 @@ private static function ValidateToken(Interaction $interaction){ } self::AssignVerfiedRole($interaction, $token); - } + self::AddToUsersTable($discord_id, $r['unt_email'], $r['id']); + } /** * Acknowledges the interaction with a loading message, then tries to add the verified role to the user. @@ -293,8 +345,7 @@ private static function ValidateToken(Interaction $interaction){ * @param string|null $token * @return void */ - public static function AssignVerfiedRole(Interaction $interaction, mixed $token): void - { + public static function AssignVerfiedRole(Interaction $interaction, mixed $token): void { $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $token) { $interaction->member->addRole(DISCORD_VERIFIED_ROLE_ID)->then(function () use ($interaction) { $interaction->updateOriginalResponse( @@ -309,7 +360,7 @@ function ($reason) use ($interaction, $token) { "There was an issue trying to adding the verified role to you. Contact an officer with your token to get your roles updated." ), ); - if($token !== null) { + if ($token !== null) { error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; token: {$token}): $reason"); } else { error_log("Error adding the verified role to user <@{$interaction->user->id}> (ID: {$interaction->user->id}; no token required): $reason"); @@ -320,12 +371,92 @@ function ($reason) use ($interaction, $token) { }); } + /** + * Adds the user to the users table. + * + * Call after the token has been verified. + * @param int|string $discord_id The user's Discord ID + * @param string $unt_email The user's UNT email. Should not be escaped by real_escape_string()) + * @param string $token_id The ID of the entry in the discord_verification_tokens table that was used to verify the user + * @return void + */ + private static function AddToUsersTable(mixed $discord_id, string $unt_email, string $token_id): void { + global $db; + $unt_email = $db->real_escape_string($unt_email); + + // check if a user registered on the website with the given UNT email + $q = $db->query("SELECT + id + FROM + users + WHERE + email = '{$unt_email}'" + ); + if ($q === false) { + error_log("Failed to add verified user to users table ({$unt_email}, {$discord_id}): {$db->error}"); + return; + } + // if the user exists, update the entry + // else, create a new entry + if ($q->num_rows >= 1) { + $id = $q->fetch_column(); + $query_string = "UPDATE + users + SET + unt_email = '{$unt_email}', + discord_id = {$discord_id} + WHERE + id = {$id}"; + } else { + $query_string = "INSERT INTO + users (unt_email, discord_id) + VALUES ('{$unt_email}', {$discord_id})"; + } + + $q = $db->query($query_string); + if ($q === false) { + if (isset($id)) { + error_log("Error UNT email ({$unt_email}) and Discord ID ({$discord_id}) to user {$id}: {$db->error}"); + } else { + error_log("Error creating user with UNT email {$unt_email} and Discord ID {$discord_id}: {$db->error}"); + } + return; + } + + if (!isset($id)) { + $q = $db->query("SELECT + id + FROM + users + WHERE + unt_email = '{$unt_email}' + AND + discord_id = {$discord_id}"); + if ($q === false) { + error_log("Error fetching newly created user to update token status ($unt_email, {$discord_id}): {$db->error}"); + return; + } + + $id = $q->fetch_column(); + } + + $q = $db->query("UPDATE + discord_verification_tokens + SET + user_id = {$id} + WHERE + id = {$token_id}"); + if ($q === false) { + error_log("Error updating token ($token_id) with user ID: {$db->error}"); + } + } + /** * Verify that an email address is a my.unt email * @param string $email The email to validate * @return bool True if the email is a UNT email. False otherwise */ - private static function IsValidEmail(string $email): bool{ + private static function IsValidEmail(string $email): bool { // regex for the domain name $valid_domains = '/@my\.unt\.edu$/i'; return filter_var($email, FILTER_VALIDATE_EMAIL) && preg_match($valid_domains, $email) === 1; @@ -336,7 +467,7 @@ private static function IsValidEmail(string $email): bool{ * @param string $token The token to validate * @return bool True if the token is in the accepted format. False otherwise */ - private static function IsValidToken(string $token): bool{ + private static function IsValidToken(string $token): bool { return ctype_xdigit($token); } } \ No newline at end of file diff --git a/sql/discord_verification_tokens.sql b/sql/discord_verification_tokens.sql index aade2ed2..c1c84453 100644 --- a/sql/discord_verification_tokens.sql +++ b/sql/discord_verification_tokens.sql @@ -2,7 +2,7 @@ DROP TABLE IF EXISTS `discord_verification_tokens`; CREATE TABLE `discord_verification_tokens`( `id` int(11) NOT NULL AUTO_INCREMENT, `token` varchar(6) NOT NULL, - `expires_on` timestamp NOT NULL, + `created_on` timestamp NOT NULL, `discord_id` bigint(20) NOT NULL, `user_id` int(11), `unt_email` varchar(255) NOT NULL, From 077128b25d897ff0952764bbd2fb9dbc7bad9c6a Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:37:41 -0500 Subject: [PATCH 09/19] Adjusted users entry creation for discord verificaiton process Updates a user entry which was created through the discord verification process (only has the unt_email and discord_id fields) with the website account details instead of making a new user account. This only happens if they sign up with the same email as in unt_email --- auth/join.php | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/auth/join.php b/auth/join.php index 0c6c61c8..e76d91f1 100644 --- a/auth/join.php +++ b/auth/join.php @@ -73,9 +73,28 @@ $timezone = "UTC"; } } - - // do query - $q = $db->query('INSERT INTO users (name, email, phone, unteuid, grad_term, grad_year, password, reg_timestamp, reg_ip, timezone) + // update user that was added from the discord verification process + $q = $db->query('SELECT id FROM users WHERE unt_email = "' . $db->real_escape_string($email) . '"'); + if($q && $q->num_rows > 0){ + $id = $q->fetch_array(MYSQLI_ASSOC)['id']; + $query_string = "UPDATE + users + SET + name = '{$db->real_escape_string($name)}', + email = '{$db->real_escape_string($email)}', + phone = '{$db->real_escape_string($phone)}', + unteuid = '{$db->real_escape_string($unteuid)}', + grad_term = '" . $db->real_escape_string(array_search($grad_term, $valid_grad_terms)) ."', + grad_year = '{$db->real_escape_string($grad_year)}', + password = '" . $db->real_escape_string(password_hash($password1, PASSWORD_BCRYPT, array('cost' => 12))) . "', + reg_timestamp = NOW(), + reg_ip = '{$db->real_escape_string($ip)}', + timezone = '{$db->real_escape_string($timezone)}' + WHERE + id = {$id} + "; + } else { + $query_string = 'INSERT INTO users (name, email, phone, unteuid, grad_term, grad_year, password, reg_timestamp, reg_ip, timezone) VALUES ( "' . $db->real_escape_string($name) . '", "' . $db->real_escape_string($email) . '", @@ -88,7 +107,10 @@ "' . $db->real_escape_string($ip) . '", "' . $db->real_escape_string($timezone) . '" ) - '); + '; + } + // do query + $q = $db->query($query_string); if ($q) { // set cookies @@ -118,6 +140,7 @@ header('Location: /auth/welcome'); } else { $error = 'An internal error occurred, please contact support.'; + require_once(__DIR__ . '/../api/discord/bots/admin.php'); AdminBot::send_message("Failed to register new user due to database error: $db->error. Please investigate."); } } From 727893102b391575418e442f39258f52f50f76b4 Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:56:27 -0500 Subject: [PATCH 10/19] Fix minor bug with mysqli::insert_id PHP documentation incorrectly states that it works for UPDATE statements; it does not --- auth/join.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/auth/join.php b/auth/join.php index e76d91f1..8bfe8779 100644 --- a/auth/join.php +++ b/auth/join.php @@ -118,7 +118,8 @@ $auth_session_id = obfuscate_hash(sha1($fingerprint . session_id())); // based on IP, time, /dev/urandom and a PHP PRNG (PLCG) and fingerprint calculated above session_regenerate_id(); $auth_session_name = obfuscate_hash(bin2hex(random_bytes(32))); // just really random - + if(!isset($id)) + $id = $db->insert_id; $db->query("INSERT INTO auth_sessions (session_id, session_name, @@ -130,7 +131,7 @@ ('".$db->real_escape_string($auth_session_id)."', '".$db->real_escape_string($auth_session_name)."', '".$db->real_escape_string($fingerprint)."', - '".$db->real_escape_string($db->insert_id)."', + '".$db->real_escape_string($id)."', '".$db->real_escape_string(0)."') ") or die($db->error); // remove this for security From 017b395128bcc8bf3a829a3bc45b8ac82b2d98e7 Mon Sep 17 00:00:00 2001 From: Kenneth-W-Chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:47:19 -0500 Subject: [PATCH 11/19] Force token and email to lowercase --- api/discord/bots/verifier.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php index 62a67233..22b5a02c 100644 --- a/api/discord/bots/verifier.php +++ b/api/discord/bots/verifier.php @@ -80,7 +80,7 @@ private static function ValidateEmail(Interaction $interaction) { } // get email passed to the bot - $email = $interaction->data->options["email"]->value; + $email = strtolower($interaction->data->options["email"]->value); // if email isn't a UNT email, provide error message to user and end interaction if (!self::IsValidEmail($email)){ $interaction->respondWithMessage( @@ -277,7 +277,7 @@ private static function ValidateToken(Interaction $interaction) { global $db; // get token passed to the bot // $token sanitized for db since it won't be used elsewhere - $token = $interaction->data->options["token"]->value; + $token = strtolower($interaction->data->options["token"]->value); $discord_id = $interaction->user->id; // if token isn't valid, tell user verification failed and end interaction if (!self::IsValidToken($token)){ From 57da9ec8115d6cca4267519a79a3d6797c143cbe Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:44:15 -0500 Subject: [PATCH 12/19] URW-167 Add website flow for UNT email verification Moved the token ratelimit const to the config file since it's used for both the discord and website flows --- ajax/verify-unt-email.php | 192 +++++++++++++++++++++++++++++++ api/discord/bots/verifier.php | 5 +- auth/discord.php | 15 ++- dues/paid.php | 29 ++++- js/script.js | 132 +++++++++++++++------ template/classes/untrobotics.php | 22 ++++ template/sample.config.php | 1 + 7 files changed, 355 insertions(+), 41 deletions(-) create mode 100644 ajax/verify-unt-email.php diff --git a/ajax/verify-unt-email.php b/ajax/verify-unt-email.php new file mode 100644 index 00000000..f4dd4b5d --- /dev/null +++ b/ajax/verify-unt-email.php @@ -0,0 +1,192 @@ +query("SELECT + id, + created_on + FROM + discord_verification_tokens + WHERE + user_id = {$auth['id']} + ORDER BY created_on DESC + LIMIT 1"); + if ($q === false) { + error_log("Could not retrieve discord verification token for user {$auth['id']}: {$db->error}"); + echo 'ERROR'; + die(); + } + if ($q->num_rows > 0) { + $r = $q->fetch_assoc(); + if (new DateTime()->sub(new DateInterval('PT' . EMAIL_TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { + echo 'TOKEN_RATELIMIT'; + die(); + } + $q = $db->query("DELETE FROM + discord_verification_tokens + WHERE + id = {$r['id']}" + ); + if($q === false){ + error_log("Could not delete discord verification token {$r['id']} for user {$auth['id']}: {$db->error}"); + echo 'ERROR'; + die(); + } + } + // generate token + $token = openssl_random_pseudo_bytes(3); + $q = $db->query("INSERT INTO discord_verification_tokens ( + token, + created_on, + user_id, + unt_email, + ) + VALUES ( + '{$token}', + CURRENT_TIMESTAMP(), + {$auth['id']}, + '{$db->real_escape_string($email)}', + )" + ); + if ($q === false) { + error_log("Could not add discord verification token to db for user {$auth['id']}: {$db->error}"); + echo 'ERROR'; + die(); + } + // send email with token + $email_send_status = email( + $email, + "UNT Robotics Discord verification token", + + "
" . + '' . + + '
' . + + '
' . + "

Hey there!

" . + "

Welcome to the team!!

" . + "

Thank you for joining the UNT Robotics Discord server!" . + "

Your verification token is:

" . + "
" . + "
" . + "

{$token}

" . + '
' . + + '
' . + + "

" . + + "

If you need any assistance, please reach out to hello@untrobotics.com.

" . + + '
' . + + '
' . + "

All the best,

" . + "

UNT Robotics Leadership

" . + '
' . + + "
", + + "hello@untrobotics.com", + null, + [ + [ + 'content' => base64_encode(file_get_contents(BASE . '/images/unt-robotics-email-header.jpg')), + 'type' => 'image/jpeg', + 'filename' => 'unt-robotics-email-header.jpg', + 'disposition' => 'inline', + 'content_id' => 'untrobotics-email-header' + ] + ] + ); + if($email_send_status === false) { + error_log("Failed to email verification token to {$email}."); + echo 'ERROR'; + // delete newly generated token from db since we couldn't email it + $q = $db->query("DELETE FROM + discord_verification_tokens + WHERE + token = '{$token}' + AND + user_id = {$auth['id']}"); + if($q === false){ + error_log("Could not delete discord verification token {$token} for user {$auth['id']}: {$db->error}"); + } + die(); + } + // changes the form to send a token instead of email + echo 'TOKEN'; + break; + } + case "token": + { + $token = $_POST['token']; + if (ctype_xdigit($token) || strlen($token) !== 6) { + echo 'INVALID_TOKEN'; + die(); + } + global $db; + // check if there's an unexpired token for the user + $q = $db->query("SELECT + unt_email + FROM + discord_verification_tokens + WHERE + token = '{$token}' + AND + user_id = {$auth['id']} + AND + created_on + INTERVAL 7 day > CURRENT_TIMESTAMP()"); + if($q === false){ + error_log("Failed to fetch token for user {$auth['id']}: {$db->error}"); + echo 'ERROR'; + die(); + } + if ($q->num_rows === 0) { + echo 'INVALID_TOKEN'; + die(); + } + // add unt_email to users entry + $email = $q->fetch_column(); + $q = $db->query("UPDATE + users + SET + unt_email = '{$db->real_escape_string($email)}' + WHERE + id = {$auth['id']}"); + if($q === false){ + error_log("Failed to add UNT email ({$email}) to user {$auth['id']}: {$db->error}"); + echo 'ERROR'; + die(); + } + + // deletes the form and shows the discord link + echo 'Click here to update your Discord account status.'; + break; + } +} \ No newline at end of file diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php index 22b5a02c..bb6ffbec 100644 --- a/api/discord/bots/verifier.php +++ b/api/discord/bots/verifier.php @@ -11,7 +11,6 @@ class VerificationBot { private $discord; - private const int TOKEN_GENERATION_RATE_LIMIT = 300; /** * Creates a VerificationBot instance. To start the webhook, call {@see VerificationBot::run()} @@ -148,7 +147,7 @@ function () use($interaction, $email) { /** @noinspection PhpUnhandledExceptionInspection */ // if ratelimit reached, stop interaction and tell user to wait before generating a new token - if (new DateTime()->sub(new DateInterval('PT' . self::TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { + if (new DateTime()->sub(new DateInterval('PT' . EMAIL_TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { $interaction->updateOriginalResponse( MessageBuilder::new()->setContent("Please wait a few minutes before requesting a new token.") ); @@ -459,7 +458,7 @@ private static function AddToUsersTable(mixed $discord_id, string $unt_email, st private static function IsValidEmail(string $email): bool { // regex for the domain name $valid_domains = '/@my\.unt\.edu$/i'; - return filter_var($email, FILTER_VALIDATE_EMAIL) && preg_match($valid_domains, $email) === 1; + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false && preg_match($valid_domains, $email) === 1; } /** diff --git a/auth/discord.php b/auth/discord.php index 937fca1b..a896f784 100644 --- a/auth/discord.php +++ b/auth/discord.php @@ -118,6 +118,10 @@ function assign_user_good_standing($user_discord_id) { return AdminBot::add_user_role($user_discord_id); } +function assign_user_verified($user_discord_id) { + return AdminBot::add_user_role($user_discord_id, DISCORD_VERIFIED_ROLE_ID); +} + head('Joined Discord', true, true); // user must be authenticated to reach this point @@ -195,6 +199,15 @@ function assign_user_good_standing($user_discord_id) { // Alert! throw new Exception("Current user is not in good standing."); } + if($untrobotics->is_user_email_verified($userinfo)) { + $assigned = assign_user_verified($user->id); + if ($assigned->status_code != 204) { + error_log("AUTHDIS", var_export($assigned, true)); + throw new Exception("Failed to give user the correct role: " . $assigned->status_code . " ($code)"); + } + } else { + throw new Exception("Current user has not verified their UNT email address."); + } ?>

You're good to go

username; ?>#discriminator; ?> has been given the Good Standing role.
@@ -228,7 +241,7 @@ function assign_user_good_standing($user_discord_id) { } } - AdminBot::send_message("[AUTHDIS] Failed to assign '{$userinfo['name']}' [{$username}#{$discriminator}] (http://untro.bo/admin/check-good-standing?u={$userinfo['id']}) to the Good Standing role.\n{$ex}"); + AdminBot::send_message("[AUTHDIS] Failed to assign '{$userinfo['name']}' [{$username}#{$discriminator}] (http://untro.bo/admin/check-good-standing?u={$userinfo['id']}) to the Good Standing or verified role.\n{$ex}"); ?>

Error!

diff --git a/dues/paid.php b/dues/paid.php index 6fe114c7..a85d6449 100644 --- a/dues/paid.php +++ b/dues/paid.php @@ -1,9 +1,10 @@
@@ -15,8 +16,30 @@

Dues Paid

Thank you for paying your dues.

- + Click here to update your Discord account status. + +
+
+
Next, please verify your UNT email address.
+
+
+
+ + +
+ +
+
+
diff --git a/js/script.js b/js/script.js index 20181061..1c85f444 100644 --- a/js/script.js +++ b/js/script.js @@ -1583,6 +1583,50 @@ $document.ready(function () { $.getScript("//www.google.com/recaptcha/api.js?onload=onloadCaptchaCallback&render=explicit&hl=en"); } + /** + * Finalizes a form's state after submission upon a successful (200) response from the server + * @param form The jQuery selector for the form + * @param formHasCaptcha A boolean representing whether the form has a captcha or not + * @param output The jQuery selector for where output text will be placed + * @param result The server response message + * @param msg An object or associative array which maps result to a message to put in output + */ + function finalizeForm(form, formHasCaptcha, output, result, msg) { + form + .addClass('success') + .removeClass('form-in-process'); + + if (formHasCaptcha) { + grecaptcha.reset(); + } + + //result = result.length == 5 ? result : 'MF255'; + output.text(msg[result]); + + if (result === "SUCCESS") { + if (output.hasClass("snackbars")) { + output.css('background-color', '#24c57c'); + output.html('

' + msg[result] + '

'); + } else { + output.addClass("active success"); + } + form.clearForm(); + } else { + if (output.hasClass("snackbars")) { + output.html('

' + msg[result] + '

'); + } else { + output.addClass("active error"); + } + } + + form.find('input, textarea').blur(); + + setTimeout(function () { + output.removeClass("active error success"); + form.removeClass('success'); + }, 3500); + } + /** * RD Mailform */ @@ -1688,40 +1732,8 @@ $document.ready(function () { var form = $(plugins.rdMailForm[this.extraData.counter]), output = $("#" + form.attr("data-form-output")); - form - .addClass('success') - .removeClass('form-in-process'); - - if (formHasCaptcha) { - grecaptcha.reset(); - } - - //result = result.length == 5 ? result : 'MF255'; - output.text(msg[result]); - - if (result === "SUCCESS") { - if (output.hasClass("snackbars")) { - output.css('background-color', '#24c57c'); - output.html('

' + msg[result] + '

'); - } else { - output.addClass("active success"); - } - form.clearForm(); - } else { - if (output.hasClass("snackbars")) { - output.html('

' + msg[result] + '

'); - } else { - output.addClass("active error"); - } - } - - form.find('input, textarea').blur(); - - setTimeout(function () { - output.removeClass("active error success"); - form.removeClass('success'); - }, 3500); - } + finalizeForm(form, formHasCaptcha, output, result, msg); + } }); } } @@ -1922,6 +1934,58 @@ $document.ready(function () { }, 300); }); } + + /** + * Custom email verification AJAX form + */ + // noinspection JSJQueryEfficiency + if ($('form#verify-email').length) { + let msg = { + "INVALID_EMAIL": "The e-mail address you entered is not valid. Please ensure the email ends with @my.unt.edu.", + "INVALID_TOKEN": "The token is incorrect or invalid.", + "INVALID_LOGIN": "Your log-in session has expired or isn't valid anymore. Please log-in again to continue.", + "INVALID_REQUEST": "The request fields are invalid.", + "TOKEN_RATELIMIT": "Please wait a few minutes before requesting a new token.", + "SUCCESS": "E-mail successfully verified.", + "ERROR": "A server error occurred. Please contact support." + } + let $form = $('form#verify-email'); + $form.ajaxForm({ + data: { + type: $form.attr('data-type'), + }, + beforeSubmit: function () { + //todo validate + return true + }, + error: function (result) { + $('#' + $form.attr('data-form-output')).text(msg[result]) + console.log(result) + }, + success: function (result) { + if (result === "TOKEN") { + $form.attr('data-type', 'token'); + $form.children('label').attr('for', 'token'); + let $input = $form.find('input').attr({ + id: 'token', + type: '', + name: 'token', + 'data-constraints': '@Required' + }); + let email = $input.val(); + $input.val('') + $form.children('h6').text("We’ve sent a verification code to " + email + ". Please check your inbox and spam/junk folder and enter the code below to verify your address.") + } else if (result.startsWith("INVALID")) { + finalizeForm($form, false, $('#' + $form.attr('data-form-output')), result, msg) + } else { + let $section = $('section#email-verification-section'); + $section.parent().append(result) + $section.remove() + finalizeForm($form, false, $('#' + $form.attr('data-form-output')), "SUCCESS", msg) + } + } + }) + } }); diff --git a/template/classes/untrobotics.php b/template/classes/untrobotics.php index f554f25f..00c7bf52 100644 --- a/template/classes/untrobotics.php +++ b/template/classes/untrobotics.php @@ -70,6 +70,28 @@ public function is_user_in_good_standing($userinfo) { return $q->num_rows === 1; } + public function is_user_email_verified($userinfo) { + $uid = null; + if (is_array($userinfo)) { + $uid = $userinfo['id']; + } else { + $uid = $userinfo; + } + $q = $this->db->query(' + SELECT id FROM users + WHERE + id = "' . $this->db->real_escape_string($uid) . '" + AND + unt_email IS NOT NULL + '); + + if (!$q) { + return false; + } + + return $q->num_rows === 1; + } + // semester, dues functions public function get_current_term() { return $this->get_term_from_date(time()); diff --git a/template/sample.config.php b/template/sample.config.php index bf51990b..0f27f7f8 100644 --- a/template/sample.config.php +++ b/template/sample.config.php @@ -87,6 +87,7 @@ ) ); define('DISCORD_VERIFIED_ROLE_ID', '1414807269580734527'); +define('EMAIL_TOKEN_GENERATION_RATE_LIMIT', 300); define('PAYPAL_PDT_ID_TOKEN', ''); define('PAYPAL_SANDBOX_PDT_ID_TOKEN', ''); From 071fbb107e0e28a7b2580d1a81816cda822723f4 Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:06:17 -0500 Subject: [PATCH 13/19] Fix rdMailForm plugin not showing error messages --- js/script.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/script.js b/js/script.js index 1c85f444..e5b4db90 100644 --- a/js/script.js +++ b/js/script.js @@ -1721,7 +1721,7 @@ $document.ready(function () { error: function (result) { var form = $(plugins.rdMailForm[this.extraData.counter]), output = $("#" + $(plugins.rdMailForm[this.extraData.counter]).attr("data-form-output")); - output.text(msg[result]); + output.text(msg[result.responseText]); form.removeClass('form-in-process'); if (formHasCaptcha) { @@ -1959,8 +1959,7 @@ $document.ready(function () { return true }, error: function (result) { - $('#' + $form.attr('data-form-output')).text(msg[result]) - console.log(result) + $('#' + $form.attr('data-form-output')).text(msg[result.responseText]) }, success: function (result) { if (result === "TOKEN") { @@ -1983,7 +1982,8 @@ $document.ready(function () { $section.remove() finalizeForm($form, false, $('#' + $form.attr('data-form-output')), "SUCCESS", msg) } - } + }, + headers: {'Cookie' : document.cookie }, }) } }); From a7f18bafca6ad48d2c94c257f8eba3541a6cdfa4 Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:08:20 -0500 Subject: [PATCH 14/19] URW-167 fix unexpected operator error PHP Storm incorrectly tries to remove the parentheses which causes the issue --- ajax/verify-unt-email.php | 4 +++- api/discord/bots/verifier.php | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ajax/verify-unt-email.php b/ajax/verify-unt-email.php index f4dd4b5d..873c6a4a 100644 --- a/ajax/verify-unt-email.php +++ b/ajax/verify-unt-email.php @@ -42,7 +42,9 @@ } if ($q->num_rows > 0) { $r = $q->fetch_assoc(); - if (new DateTime()->sub(new DateInterval('PT' . EMAIL_TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { + /** @noinspection PhpParenthesesCanBeOmittedForNewCallInspection */ + /** @noinspection PhpUnhandledExceptionInspection */ + if ((new DateTime())->sub(new DateInterval('PT' . EMAIL_TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { echo 'TOKEN_RATELIMIT'; die(); } diff --git a/api/discord/bots/verifier.php b/api/discord/bots/verifier.php index bb6ffbec..d8c7be05 100644 --- a/api/discord/bots/verifier.php +++ b/api/discord/bots/verifier.php @@ -147,7 +147,8 @@ function () use($interaction, $email) { /** @noinspection PhpUnhandledExceptionInspection */ // if ratelimit reached, stop interaction and tell user to wait before generating a new token - if (new DateTime()->sub(new DateInterval('PT' . EMAIL_TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { + /** @noinspection PhpParenthesesCanBeOmittedForNewCallInspection */ + if ((new DateTime())->sub(new DateInterval('PT' . EMAIL_TOKEN_GENERATION_RATE_LIMIT . 'S')) < new DateTime($r['created_on'])) { $interaction->updateOriginalResponse( MessageBuilder::new()->setContent("Please wait a few minutes before requesting a new token.") ); From 68101be387d38db6f06c1a1f57b179b2301eca1f Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:33:51 -0500 Subject: [PATCH 15/19] Fix minor issues --- ajax/verify-unt-email.php | 7 ++++--- sql/discord_verification_tokens.sql | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ajax/verify-unt-email.php b/ajax/verify-unt-email.php index 873c6a4a..87cba5b3 100644 --- a/ajax/verify-unt-email.php +++ b/ajax/verify-unt-email.php @@ -6,6 +6,7 @@ echo 'INVALID_LOGIN'; die(); } +$auth = $auth[0]; if (!isset($_POST)) { die(); } @@ -60,18 +61,18 @@ } } // generate token - $token = openssl_random_pseudo_bytes(3); + $token = bin2hex(openssl_random_pseudo_bytes(3)); $q = $db->query("INSERT INTO discord_verification_tokens ( token, created_on, user_id, - unt_email, + unt_email ) VALUES ( '{$token}', CURRENT_TIMESTAMP(), {$auth['id']}, - '{$db->real_escape_string($email)}', + '{$db->real_escape_string($email)}' )" ); if ($q === false) { diff --git a/sql/discord_verification_tokens.sql b/sql/discord_verification_tokens.sql index c1c84453..acf0383f 100644 --- a/sql/discord_verification_tokens.sql +++ b/sql/discord_verification_tokens.sql @@ -3,7 +3,7 @@ CREATE TABLE `discord_verification_tokens`( `id` int(11) NOT NULL AUTO_INCREMENT, `token` varchar(6) NOT NULL, `created_on` timestamp NOT NULL, - `discord_id` bigint(20) NOT NULL, + `discord_id` bigint(20), `user_id` int(11), `unt_email` varchar(255) NOT NULL, PRIMARY KEY(id), From 9334e37b7353d7f63547b8129a6dbdae031a9ac5 Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:15:30 -0500 Subject: [PATCH 16/19] URW-167 Fixed form not working due to plugin shenanigans and some backend issues Plugin doesn't like newly created input fields and changes to the form html --- ajax/verify-unt-email.php | 2 +- dues/paid.php | 9 ++++++--- js/script.js | 27 +++++++++++++-------------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/ajax/verify-unt-email.php b/ajax/verify-unt-email.php index 87cba5b3..a1d5da43 100644 --- a/ajax/verify-unt-email.php +++ b/ajax/verify-unt-email.php @@ -149,7 +149,7 @@ case "token": { $token = $_POST['token']; - if (ctype_xdigit($token) || strlen($token) !== 6) { + if (!ctype_xdigit($token) || strlen($token) !== 6) { echo 'INVALID_TOKEN'; die(); } diff --git a/dues/paid.php b/dues/paid.php index a85d6449..98dedfe5 100644 --- a/dues/paid.php +++ b/dues/paid.php @@ -29,12 +29,15 @@
Next, please verify your UNT email address.
-
+
- + + + +
- +
Date: Sun, 21 Sep 2025 10:38:54 -0500 Subject: [PATCH 17/19] URW-167 fix form snackbar not showing up in some cases This fix applies to RDMailForm too Added validation --- js/script.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/js/script.js b/js/script.js index a69980bd..24220bf4 100644 --- a/js/script.js +++ b/js/script.js @@ -1722,8 +1722,8 @@ $document.ready(function () { var form = $(plugins.rdMailForm[this.extraData.counter]), output = $("#" + $(plugins.rdMailForm[this.extraData.counter]).attr("data-form-output")); output.text(msg[result.responseText]); + output.addClass('active error') form.removeClass('form-in-process'); - if (formHasCaptcha) { grecaptcha.reset(); } @@ -1950,13 +1950,22 @@ $document.ready(function () { "ERROR": "A server error occurred. Please contact support." } let $form = $('form#verify-email'); + let $output = $('#' + $form.attr('data-form-output')) $form.ajaxForm({ beforeSubmit: function () { - //todo validate - return true + if(isValidated($form.find('input:not([type="hidden"])'), false)) { + $output.removeClass("active error success"); + $form.addClass('form-in-process'); + if ($output.hasClass("snackbars")) { + $output.html('

Sending

'); + $output.addClass("active"); + } + return true + } + return false }, error: function (result) { - $('#' + $form.attr('data-form-output')).text(msg[result.responseText]) + $output.text(msg[result.responseText]).addClass('active error') }, success: function (result) { if (result === "TOKEN") { @@ -1973,13 +1982,13 @@ $document.ready(function () { $form.find('input#type').val('token') $form.siblings('div').children('h6').text("We’ve sent a verification token to " + email + ". Please check your inbox and spam/junk folder and enter the code below to verify your address.") $form.find('button').text('Verify') - } else if (result.startsWith("INVALID") || result.startsWith('TOKEN_RATELIMIT')) { - finalizeForm($form, false, $('#' + $form.attr('data-form-output')), result, msg) + } else if (result.startsWith("INVALID") || result.startsWith('TOKEN_RATELIMIT') || result.startsWith("ERROR")) { + finalizeForm($form, false, $output, result, msg) } else { let $section = $('section#email-verification-section'); $section.parent().append(result) $section.remove() - finalizeForm($form, false, $('#' + $form.attr('data-form-output')), "SUCCESS", msg) + finalizeForm($form, false, $output, "SUCCESS", msg) } }, headers: {'Cookie' : document.cookie }, From edc355f435866c756acfed3fa91d841f00440152 Mon Sep 17 00:00:00 2001 From: kenneth-w-chen <74205780+Kenneth-W-Chen@users.noreply.github.com> Date: Sun, 21 Sep 2025 11:14:19 -0500 Subject: [PATCH 18/19] URW-167 Minor bug fixes Fix snackbar not disappearing for initial token request Fix token form showing up regardless of verification status --- dues/paid.php | 2 +- js/script.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dues/paid.php b/dues/paid.php index 98dedfe5..6915ca31 100644 --- a/dues/paid.php +++ b/dues/paid.php @@ -18,7 +18,7 @@

Thank you for paying your dues.

Click here to update your Discord account status. Date: Sun, 21 Sep 2025 11:22:57 -0500 Subject: [PATCH 19/19] URW-167 Un-comment out error logging --- dues/paid.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dues/paid.php b/dues/paid.php index 6915ca31..40cb5121 100644 --- a/dues/paid.php +++ b/dues/paid.php @@ -3,8 +3,8 @@ head('Dues Paid', true); $userinfo = auth(); -//$log = var_export($_REQUEST, true); -//error_log($log, 3, BASE . '/paypal/logs/pdt-dues.log'); +$log = var_export($_REQUEST, true); +error_log($log, 3, BASE . '/paypal/logs/pdt-dues.log'); ?>