diff options
| -rw-r--r--[-rwxr-xr-x] | example.env | 10 | ||||
| -rw-r--r-- | http/php/config.php | 19 | ||||
| -rw-r--r-- | http/public/do/send_email.php | 124 | ||||
| -rw-r--r-- | http/public/do/send_otp.php | 104 | ||||
| -rw-r--r-- | http/public/do/verify_otp.php | 63 |
5 files changed, 319 insertions, 1 deletions
diff --git a/example.env b/example.env index d4fab80..5fa480e 100755..100644 --- a/example.env +++ b/example.env @@ -3,9 +3,17 @@ LOCAL_SMTP_USER= # The envelope-from used by the local SMTP server -# Recommended to be the same as REMOTE_SMTP_USER, else the remote may reject it +# Theoretically should be "LOCAL_SMTP_FROM_NAME <LOCAL_SMTP_FROM>", but in +# practice this seems to cause bugs, while setting it to LOCAL_SMTP_FROM works LOCAL_SMTP_ENVELOPE_FROM= +# The from address used by the local SMTP server +# Recommended to be the same as REMOTE_SMTP_USER, else the remote may reject it +LOCAL_SMTP_FROM= + +# The from name used by the local SMTP server +LOCAL_SMTP_FROM_NAME= + # The host of the remote SMTP server, e.g. smtp.gmail.com REMOTE_SMTP_HOST= diff --git a/http/php/config.php b/http/php/config.php index 9fee39a..a83b493 100644 --- a/http/php/config.php +++ b/http/php/config.php @@ -1,5 +1,24 @@ <?php namespace JoeacNet\Http\Config; +function maxDailyEmails(): int|null +{ + $maxDailyEmails = getenv("MAX_DAILY_EMAILS"); + if (!is_numeric($maxDailyEmails)) { + return null; + } + return $maxDailyEmails; +} + +function contactMailbox(): string|null +{ + return (string) getenv("CONTACT_MAILBOX"); +} + +function contactMailboxName(): string|null +{ + return (string) getenv("CONTACT_MAILBOX_NAME"); +} + function localSmtpFrom(): string|null { return (string) getenv("LOCAL_SMTP_FROM"); diff --git a/http/public/do/send_email.php b/http/public/do/send_email.php new file mode 100644 index 0000000..51d8b19 --- /dev/null +++ b/http/public/do/send_email.php @@ -0,0 +1,124 @@ +<?php + +use JoeacNet\Http\Config; +use JoeacNet\Http\Db; +use JoeacNet\Http\Mail; + +require_once __DIR__ . "/../../vendor/autoload.php"; +require_once __DIR__ . "/../../php/config.php"; +require_once __DIR__ . "/../../php/db.php"; +require_once __DIR__ . "/../../php/mail.php"; + +$db = Db\connectDb(); + +$payload = json_decode(file_get_contents("php://input"), true); +$name = getNameFromPayloadAndValidate($payload); +$email = getEmailFromPayloadAndValidate($payload); +$message = getMessageFromPayloadAndValidate($payload); +$token = getTokenFromPayloadAndValidate($payload); +$userId = getUserIdFromPayloadAndValidate($payload); + +limitMaxDailyEmails($db); +verifyToken(db: $db, userId: $userId, token: $token); +Db\deleteAllTokensForUser($db, $userId); + +$messageId = Mail\sendEmail( + subject: "joeac.net: $name left a message", + replyToEmail: $email, + replyToName: $name, + toEmail: Config\contactMailbox(), + toName: Config\contactMailboxName(), + body: "$name <$email> sent you a message:\n\n\n$message", +); +Db\recordSentEmail($db, $messageId); +syslog(LOG_INFO, "Sent an email to Joe. Message ID: $messageId"); + +http_response_code(200); +echo $messageId; +exit(); + +/// functions /// + +function verifyToken(PDO $db, string $userId, string $token) +{ + if (!Db\isTokenValid($db, $userId, $token)) { + http_response_code(400); + echo "token is not valid"; + exit(); + } +} + +function limitMaxDailyEmails(PDO $db) +{ + $countEmailsSentLast24Hours = Db\countEmailsSentLast24Hours($db); + if ($countEmailsSentLast24Hours > Config\maxDailyEmails()) { + $msg = + "$countEmailsSentLast24Hours emails have been sent in the last 24 hours, but the max daily load is " . + Config\maxDailyEmails() . + "."; + syslog(LOG_WARNING, $msg); + http_response_code(500); + echo $msg; + exit(); + } +} + +function getNameFromPayloadAndValidate(array $payload): string +{ + $name = (string) $payload["name"]; + if (is_null($name) || $name == "") { + http_response_code(400); + echo "name must not be empty"; + exit(); + } + return $name; +} + +function getEmailFromPayloadAndValidate(array $payload): string +{ + $email = (string) $payload["email"]; + if (is_null($email) || $email == "") { + http_response_code(400); + echo "email must not be empty"; + exit(); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + http_response_code(400); + echo "<$email> is not a valid email address."; + exit(); + } + return $email; +} + +function getMessageFromPayloadAndValidate(array $payload): string +{ + $message = (string) $payload["message"]; + if (is_null($message) || $message == "") { + http_response_code(400); + echo "message must not be empty"; + exit(); + } + return $message; +} + +function getTokenFromPayloadAndValidate(array $payload): string +{ + $token = (string) $payload["token"]; + if (is_null($token) || $token == "") { + http_response_code(400); + echo "token must not be empty"; + exit(); + } + return $token; +} + +function getUserIdFromPayloadAndValidate(array $payload): string +{ + $userId = (string) $payload["userId"]; + if (is_null($userId) || $userId == "") { + http_response_code(400); + echo "userId must not be empty"; + exit(); + } + return $userId; +} diff --git a/http/public/do/send_otp.php b/http/public/do/send_otp.php new file mode 100644 index 0000000..5ca4d85 --- /dev/null +++ b/http/public/do/send_otp.php @@ -0,0 +1,104 @@ +<?php + +use JoeacNet\Http\Config; +use JoeacNet\Http\Db; +use JoeacNet\Http\Mail; + +require_once __DIR__ . "/../../php/config.php"; +require_once __DIR__ . "/../../php/db.php"; +require_once __DIR__ . "/../../php/mail.php"; + +const SEND_OTP_TYPES = ["email"]; + +$db = Db\connectDb(); + +$payload = json_decode(file_get_contents("php://input"), true); +$email = getEmailFromPayloadAndValidate($payload); +$name = getNameFromPayloadAndValidate($payload); +$type = getTypeFromPayloadAndValidate($payload); + +limitMaxDailyEmails(db: $db, email: $email, name: $name); + +$otp = strtoupper(bin2hex(random_bytes(3))); +$prettyOtp = substr($otp, 0, 3) . "-" . substr($otp, 3, 6); +Db\insertOtp(db: $db, userId: $email, otp: $otp); + +try { + $messageId = Mail\sendEmail( + toEmail: $email, + toName: $name, + subject: "joeac.net: your OTP is $prettyOtp", + body: <<<BODY + Someone tried to use this email address on joeac.net. If this was you, + your one-time passcode is $prettyOtp. If this wasn't you, you don't need + to do anything. + BODY, + ); +} catch (Exception $e) { + error_log("Email could not be sent: $e"); + http_response_code(500); + echo "Email could not be sent: $e"; + exit(); +} + +Db\recordSentEmail($db, $messageId); +syslog(LOG_INFO, "Sent OTP ($prettyOtp) to $email. Message ID: $messageId"); + +http_response_code(200); +echo $messageId; +exit(); + +/// functions /// + +function limitMaxDailyEmails(PDO $db, string $email, string $name) +{ + $countEmailsSentLast24Hours = Db\countEmailsSentLast24Hours($db); + if ($countEmailsSentLast24Hours > Config\maxDailyEmails()) { + $msg = + "$name <$email> requested an OTP email, but $countEmailsSentLast24Hours emails have already been sent, whereas the max daily load is " . + Config\maxDailyEmails() . + "."; + syslog(LOG_WARNING, $msg); + http_response_code(500); + echo $msg; + exit(); + } +} + +function getNameFromPayloadAndValidate(array $payload): string +{ + $name = (string) $payload["name"]; + if (is_null($name) || $name == "") { + http_response_code(400); + echo "name must not be empty"; + exit(); + } + return $name; +} + +function getEmailFromPayloadAndValidate(array $payload): string +{ + $email = (string) $payload["email"]; + if (is_null($email) || $email == "") { + http_response_code(400); + echo "email must not be empty"; + exit(); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + http_response_code(400); + echo "<$email> is not a valid email address."; + exit(); + } + return $email; +} + +function getTypeFromPayloadAndValidate(array $payload): string +{ + $type = (string) $payload["type"]; + if (!in_array($type, SEND_OTP_TYPES)) { + http_response_code(400); + echo "type must be one of: " . SEND_OTP_TYPES; + exit(); + } + return $type; +} diff --git a/http/public/do/verify_otp.php b/http/public/do/verify_otp.php new file mode 100644 index 0000000..7f86858 --- /dev/null +++ b/http/public/do/verify_otp.php @@ -0,0 +1,63 @@ +<?php + +use JoeacNet\Http\Db; + +require_once __DIR__ . "/../../php/db.php"; + +$db = Db\connectDb(); + +$payload = json_decode(file_get_contents("php://input"), true); +$guess = getGuessFromPayloadAndValidate($payload); +$leniencySecs = getLeniencySecsFromPayloadAndValidate($payload); +$userId = getUserIdFromPayloadAndValidate($payload); + +if ( + !Db\isOtpCorrect( + db: $db, + userId: $userId, + guess: $guess, + leniencySecs: $leniencySecs, + ) +) { + http_response_code(400); + echo "OTP is not valid"; + exit(); +} +Db\deleteAllOtpsForUser($db, $userId); +$token = bin2hex(random_bytes(256)); +Db\insertSendEmailToken(db: $db, userId: $userId, token: $token); +http_response_code(200); +echo $token; +exit(); + +/// functions /// + +function getGuessFromPayloadAndValidate(array $payload): string +{ + $guess = (string) $payload["guess"]; + if (strlen($guess) != 6) { + http_response_code(400); + echo "guess must be six characters long"; + exit(); + } + return $guess; +} + +function getLeniencySecsFromPayloadAndValidate(array $payload): int +{ + if ((bool) $payload["lenient"] ?? false) { + return 60; + } + return 0; +} + +function getUserIdFromPayloadAndValidate(array $payload): string +{ + $userId = (string) $payload["userId"]; + if (is_null($userId) || $userId == "") { + http_response_code(400); + echo "userId must not be empty"; + exit(); + } + return $userId; +} |
