summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoe Carstairs <me@joeac.net>2026-06-04 15:14:25 +0100
committerJoe Carstairs <me@joeac.net>2026-06-04 15:16:34 +0100
commit171c957c40172a994189cdc1824421b3a9bd379b (patch)
tree179ce57eef7d02c1d8a76e0a4bc32d895acdf75f
parent231ffbd0e88735bce6b7f337a09b99b07a214c86 (diff)
http: adds php api routes under do/
-rw-r--r--[-rwxr-xr-x]example.env10
-rw-r--r--http/php/config.php19
-rw-r--r--http/public/do/send_email.php124
-rw-r--r--http/public/do/send_otp.php104
-rw-r--r--http/public/do/verify_otp.php63
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;
+}