Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6d17261c | |||
| 668c105ccf | |||
| 813836fbba | |||
| fac1199cd6 | |||
| 171c957c40 | |||
| 231ffbd0e8 | |||
| e4cd434586 | |||
| 38370c101f | |||
| a89e757718 | |||
| e58cbf7b72 | |||
| 902ad835b4 | |||
| 3f4df7c503 | |||
| a304b1f744 | |||
| 94e5dfd091 | |||
| bdd64372fd | |||
| ec2cd858bd | |||
| 6bc4552d41 | |||
| 86f40be2cb | |||
| 7ac7ed3cec | |||
| a1866254f9 | |||
| b7fc80b5f5 | |||
| 629dd5525e |
Executable → Regular
+9
-1
@@ -3,9 +3,17 @@
|
|||||||
LOCAL_SMTP_USER=
|
LOCAL_SMTP_USER=
|
||||||
|
|
||||||
# The envelope-from used by the local SMTP server
|
# 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=
|
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
|
# The host of the remote SMTP server, e.g. smtp.gmail.com
|
||||||
REMOTE_SMTP_HOST=
|
REMOTE_SMTP_HOST=
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
public
|
out
|
||||||
|
vendor
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
all: public
|
all: clean out
|
||||||
|
|
||||||
public: $(shell find src -type f) $(shell find share -type f)
|
out: $(shell find src -type f) $(shell find share -type f) $(shell find bin)
|
||||||
./bin/mkws https://joeac.net
|
./bin/mkws https://joeac.net
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -rf public
|
rm -rf out
|
||||||
|
|||||||
+11
-2
@@ -9,9 +9,18 @@ usage() {
|
|||||||
test $# -lt 1 && usage
|
test $# -lt 1 && usage
|
||||||
|
|
||||||
export SRC="$(realpath src)"
|
export SRC="$(realpath src)"
|
||||||
|
export PUBLIC="$(realpath public)"
|
||||||
export SHARE="$(realpath share)"
|
export SHARE="$(realpath share)"
|
||||||
export OUT="$(realpath public)"
|
export OUT="$(realpath out)"
|
||||||
mkdir -p ${OUT}
|
|
||||||
|
if [ -d "${PUBLIC}" ]
|
||||||
|
then
|
||||||
|
echo "Copying from $(basename "${PUBLIC}") into $(basename "${OUT}")"
|
||||||
|
cp -r "${PUBLIC}" "${OUT}"
|
||||||
|
else
|
||||||
|
echo "No $(basename "${PUBLIC}") folder found"
|
||||||
|
mkdir -p "${OUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! test -f "${SRC}"/index.upphtml
|
if ! test -f "${SRC}"/index.upphtml
|
||||||
then
|
then
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "joeacnet/http",
|
||||||
|
"require": {
|
||||||
|
"phpmailer/phpmailer": "^7.1"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"JoeacNet\\Http\\": "php/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Joe Carstairs",
|
||||||
|
"email": "me@joeac.net"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+101
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "752c91265ea3414ab13b2ff0f8925706",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "phpmailer/phpmailer",
|
||||||
|
"version": "v7.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPMailer/PHPMailer.git",
|
||||||
|
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
|
||||||
|
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-hash": "*",
|
||||||
|
"php": ">=5.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||||
|
"doctrine/annotations": "^1.2.6 || ^1.13.3",
|
||||||
|
"php-parallel-lint/php-console-highlighter": "^1.0.0",
|
||||||
|
"php-parallel-lint/php-parallel-lint": "^1.3.2",
|
||||||
|
"phpcompatibility/php-compatibility": "^10.0.0@dev",
|
||||||
|
"squizlabs/php_codesniffer": "^3.13.5",
|
||||||
|
"yoast/phpunit-polyfills": "^1.0.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
|
||||||
|
"directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
|
||||||
|
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
|
||||||
|
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
|
||||||
|
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
|
||||||
|
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
|
||||||
|
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
|
||||||
|
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
|
||||||
|
"psr/log": "For optional PSR-3 debug logging",
|
||||||
|
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
|
||||||
|
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PHPMailer\\PHPMailer\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1-only"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Marcus Bointon",
|
||||||
|
"email": "phpmailer@synchromedia.co.uk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jim Jagielski",
|
||||||
|
"email": "jimjag@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Andy Prevost",
|
||||||
|
"email": "codeworxtech@users.sourceforge.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brent R. Matzelle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
|
||||||
|
"source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Synchro",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-18T08:06:14+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": [],
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": [],
|
||||||
|
"platform-dev": [],
|
||||||
|
"plugin-api-version": "2.3.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?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");
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSmtpFromName(): string|null
|
||||||
|
{
|
||||||
|
return (string) getenv("LOCAL_SMTP_FROM_NAME");
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSmtpHost(): string|null
|
||||||
|
{
|
||||||
|
return (string) getenv("LOCAL_SMTP_HOST");
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSmtpPort(): string|null
|
||||||
|
{
|
||||||
|
return (string) getenv("LOCAL_SMTP_PORT");
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSmtpUser(): string|null
|
||||||
|
{
|
||||||
|
return (string) getenv("LOCAL_SMTP_USER");
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSmtpPass(): string|null
|
||||||
|
{
|
||||||
|
return (string) getenv("LOCAL_SMTP_PASSWORD");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbScheme(): string|null
|
||||||
|
{
|
||||||
|
return (string) getenv("DB_SCHEME");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbPath(): string|null
|
||||||
|
{
|
||||||
|
return (string) getenv("DB_PATH");
|
||||||
|
}
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
<?php namespace JoeacNet\Http\Db;
|
||||||
|
|
||||||
|
use JoeacNet\Http\Config;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
require_once __DIR__ . "/config.php";
|
||||||
|
|
||||||
|
function connectDb(): PDO
|
||||||
|
{
|
||||||
|
$pdo = new PDO(Config\dbScheme() . ":" . Config\dbPath());
|
||||||
|
$pdo = createTables($pdo);
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTables(PDO $pdo): PDO
|
||||||
|
{
|
||||||
|
$pdo->exec(
|
||||||
|
<<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS otp (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
createdAtUnixSecs INTEGER NOT NULL,
|
||||||
|
validUntilUnixSecs INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
<<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS send_email_token (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
createdAtUnixSecs INTEGER NOT NULL,
|
||||||
|
validUntilUnixSecs INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
<<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS sent_emails (
|
||||||
|
messageId TEXT NOT NULL,
|
||||||
|
sentAtUnixSecs INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countEmailsSentLast24Hours(PDO $db): int
|
||||||
|
{
|
||||||
|
$twentyFourHoursAgo = time() - 24 * 60 * 60;
|
||||||
|
return (int) $db
|
||||||
|
->query(
|
||||||
|
<<<SQL
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM sent_emails
|
||||||
|
WHERE sentAtUnixSecs >= $twentyFourHoursAgo;
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
)
|
||||||
|
->fetchColumn(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSendEmailToken(PDO $db, string $userId, string $token)
|
||||||
|
{
|
||||||
|
$createdAtUnixSecs = time();
|
||||||
|
$validUntilUnixSecs = $createdAtUnixSecs + 60;
|
||||||
|
$db
|
||||||
|
->prepare(
|
||||||
|
<<<SQL
|
||||||
|
INSERT
|
||||||
|
INTO send_email_token
|
||||||
|
(userId, value, createdAtUnixSecs, validUntilUnixSecs)
|
||||||
|
VALUES
|
||||||
|
(:userId, :token, :createdAtUnixSecs, :validUntilUnixSecs);
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
)
|
||||||
|
->execute([
|
||||||
|
"userId" => $userId,
|
||||||
|
"token" => $token,
|
||||||
|
"createdAtUnixSecs" => $createdAtUnixSecs,
|
||||||
|
"validUntilUnixSecs" => $validUntilUnixSecs,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenValid(PDO $db, string $userId, string $token): int
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
<<<SQL
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM send_email_token
|
||||||
|
WHERE userId = :userId
|
||||||
|
AND value = :token
|
||||||
|
AND validUntilUnixSecs >= :validUntilUnixSecs;
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
"userId" => $userId,
|
||||||
|
"token" => $token,
|
||||||
|
"validUntilUnixSecs" => $now,
|
||||||
|
]);
|
||||||
|
$validTokenCount = (int) $stmt->fetchColumn(0);
|
||||||
|
return $validTokenCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAllTokensForUser(PDO $db, string $userId)
|
||||||
|
{
|
||||||
|
$db
|
||||||
|
->prepare(
|
||||||
|
<<<SQL
|
||||||
|
DELETE
|
||||||
|
FROM send_email_token
|
||||||
|
WHERE userId = :userId;
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
)
|
||||||
|
->execute(["userId" => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAllOtpsForUser(PDO $db, string $userId)
|
||||||
|
{
|
||||||
|
$db
|
||||||
|
->prepare(
|
||||||
|
<<<SQL
|
||||||
|
DELETE
|
||||||
|
FROM otp
|
||||||
|
WHERE userId = :userId;
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
)
|
||||||
|
->execute(["userId" => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSentEmail(PDO $db, string $messageId)
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
<<<SQL
|
||||||
|
INSERT
|
||||||
|
INTO sent_emails
|
||||||
|
(messageId, sentAtUnixSecs)
|
||||||
|
VALUES
|
||||||
|
(:messageId, :now);
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
);
|
||||||
|
$stmt->execute(["messageId" => $messageId, "now" => $now]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertOtp(PDO $db, string $userId, string $otp)
|
||||||
|
{
|
||||||
|
$createdAtUnixSecs = time();
|
||||||
|
$validUntilUnixSecs = $createdAtUnixSecs + 60 * 5;
|
||||||
|
$db
|
||||||
|
->prepare(
|
||||||
|
<<<SQL
|
||||||
|
INSERT
|
||||||
|
INTO otp
|
||||||
|
(userId, value, createdAtUnixSecs, validUntilUnixSecs)
|
||||||
|
VALUES
|
||||||
|
(:userId, :otp, :createdAtUnixSecs, :validUntilUnixSecs)
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
)
|
||||||
|
->execute([
|
||||||
|
"userId" => $userId,
|
||||||
|
"otp" => $otp,
|
||||||
|
"createdAtUnixSecs" => $createdAtUnixSecs,
|
||||||
|
"validUntilUnixSecs" => $validUntilUnixSecs,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOtpCorrect(
|
||||||
|
PDO $db,
|
||||||
|
string $userId,
|
||||||
|
string $guess,
|
||||||
|
int $leniencySecs,
|
||||||
|
): bool {
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
<<<SQL
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM otp
|
||||||
|
WHERE userId = :userId
|
||||||
|
AND value = :guess
|
||||||
|
AND validUntilUnixSecs >= :validUntilUnixSecs
|
||||||
|
SQL
|
||||||
|
,
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
"userId" => $userId,
|
||||||
|
"guess" => $guess,
|
||||||
|
"validUntilUnixSecs" => time() - $leniencySecs,
|
||||||
|
]);
|
||||||
|
return $stmt->fetchColumn(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php namespace JoeacNet\Http\Mail;
|
||||||
|
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
|
||||||
|
use JoeacNet\Http\Config;
|
||||||
|
|
||||||
|
require_once __DIR__ . "/../vendor/autoload.php";
|
||||||
|
|
||||||
|
function sendEmail(
|
||||||
|
string $toEmail,
|
||||||
|
string $toName,
|
||||||
|
string $subject,
|
||||||
|
string $body,
|
||||||
|
string $replyToEmail = null,
|
||||||
|
string $replyToName = null,
|
||||||
|
): string {
|
||||||
|
$mail = new PHPMailer();
|
||||||
|
$mail->isSMTP();
|
||||||
|
$mail->Host = Config\localSmtpHost();
|
||||||
|
$mail->Port = Config\localSmtpPort();
|
||||||
|
$mail->SMTPAuth = true;
|
||||||
|
$mail->PlainAuth = true;
|
||||||
|
$mail->Username = Config\localSmtpUser();
|
||||||
|
$mail->Password = Config\localSmtpPass();
|
||||||
|
$mail->setFrom(Config\localSmtpFrom(), Config\localSmtpFromName());
|
||||||
|
if (!is_null($replyToEmail)) {
|
||||||
|
$mail->addReplyTo($replyToEmail, $replyToName);
|
||||||
|
}
|
||||||
|
$mail->addAddress($toEmail, $toName);
|
||||||
|
$mail->isHTML(false);
|
||||||
|
$mail->Subject = "joeac.net: $subject";
|
||||||
|
$mail->Body = "$body";
|
||||||
|
$mail->send();
|
||||||
|
return $mail->getLastMessageID();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../common/images/
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export const actions = {
|
||||||
|
/**
|
||||||
|
* @param {{type: 'email', name: string, email: string}}
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
sendOtp: async ({ type, name, email }) => {
|
||||||
|
const url = "/do/send_otp.php";
|
||||||
|
const req = new Request(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ type, name, email }),
|
||||||
|
});
|
||||||
|
const res = await fetch(req);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Request to ${url} failed: ${res.status} ${res.statusText} ${await res.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{guess: string, lenient?: bool, userId: string}}
|
||||||
|
* @returns {Promise<string | false>}
|
||||||
|
*/
|
||||||
|
verifyOtp: async ({ guess, lenient, userId }) => {
|
||||||
|
const url = "/do/verify_otp.php";
|
||||||
|
const req = new Request(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ guess, lenient, userId }),
|
||||||
|
});
|
||||||
|
const res = await fetch(req);
|
||||||
|
|
||||||
|
if (res.status === 400) {
|
||||||
|
return false;
|
||||||
|
} else if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Request to ${url} failed: ${res.status} ${res.statusText} ${await res.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.text();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{email: string, message: string, name: string, userId: string, token: string}}
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
sendEmail: async ({ email, message, name, userId, token }) => {
|
||||||
|
const url = "/do/send_email.php";
|
||||||
|
const req = new Request(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, message, name, userId, token }),
|
||||||
|
});
|
||||||
|
const res = await fetch(req);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Request to ${url} failed: ${res.status} ${res.statusText} ${await res.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** @typedef {import("./selectors.js").Selectors} Selectors */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{contactForm: Selectors}} contactForm
|
||||||
|
* @param {string} errorMsg
|
||||||
|
*/
|
||||||
|
export function postErrorMessageOnContactForm({ contactForm }, errorMsg) {
|
||||||
|
const errorElem = contactForm.errorElem();
|
||||||
|
if (errorElem) {
|
||||||
|
errorElem.textContent = errorMsg;
|
||||||
|
errorElem.removeAttribute("hidden");
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{otpDialog: Selectors}} otpDialog
|
||||||
|
* @param {string} errorMsg
|
||||||
|
*/
|
||||||
|
export function postErrorMessageOnOtpForm({ otpDialog }, errorMsg) {
|
||||||
|
const errorElem = otpDialog.errorElem();
|
||||||
|
if (errorElem) {
|
||||||
|
errorElem.textContent = errorMsg;
|
||||||
|
errorElem.removeAttribute("hidden");
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
/** @type NodeListOf<HTMLInputElement> */
|
||||||
|
const inputs = otpDialog.allOtpInputs();
|
||||||
|
for (const input of inputs) {
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** @typedef {import("./selectors.js").Selectors} Selectors */
|
||||||
|
|
||||||
|
import { actions } from "./actions.js";
|
||||||
|
import { resetResendButton } from "./reset-resend-button.js";
|
||||||
|
import { postErrorMessageOnOtpForm } from "./post-error-message.js";
|
||||||
|
|
||||||
|
/** @typedef {{ resendButtonInterval: NodeJS.Timeout | undefined }} Result */
|
||||||
|
|
||||||
|
const fallbackErrorMsg =
|
||||||
|
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Selectors} selectors
|
||||||
|
* @param {NodeJS.Timeout | undefined} resetButtonInterval
|
||||||
|
* @returns {Promise<Result>}
|
||||||
|
*/
|
||||||
|
export async function resendOtp(selectors, resetButtonInterval) {
|
||||||
|
const result = resetResendButton(selectors, resetButtonInterval);
|
||||||
|
const name = selectors.contactForm.nameElem()?.value;
|
||||||
|
const email = selectors.contactForm.emailElem().value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actions.sendOtp({ type: "email", name, email });
|
||||||
|
} catch (sendOtpError) {
|
||||||
|
const errorMsg = sendOtpError?.toString() ?? fallbackErrorMsg;
|
||||||
|
postErrorMessageOnOtpForm(selectors, errorMsg);
|
||||||
|
throw sendOtpError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** @typedef {import("./selectors.js").Selectors} Selectors */
|
||||||
|
|
||||||
|
/** @typedef {{ resendButtonInterval: NodeJS.Timeout | undefined }} Result */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Selectors} selectors
|
||||||
|
* @param {NodeJS.Timeout | undefined} resetButtonInterval
|
||||||
|
* @returns {Result}
|
||||||
|
*/
|
||||||
|
export function resetResendButton({ otpDialog }, resendButtonInterval) {
|
||||||
|
clearInterval(resendButtonInterval);
|
||||||
|
|
||||||
|
const resendButton = otpDialog.resendButton();
|
||||||
|
if (resendButton) {
|
||||||
|
resendButton.setAttribute("data-countdown", "60");
|
||||||
|
resendButton.setAttribute("disabled", "");
|
||||||
|
|
||||||
|
resendButtonInterval = setInterval(() => {
|
||||||
|
const countdown = +(resendButton.getAttribute("data-countdown") ?? 1) - 1;
|
||||||
|
resendButton.setAttribute("data-countdown", countdown.toString());
|
||||||
|
resendButton.textContent = `Resend (${countdown}s)`;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(resendButtonInterval);
|
||||||
|
resendButton.textContent = "Resend";
|
||||||
|
resendButton.removeAttribute("disabled");
|
||||||
|
}, 1000 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resendButtonInterval };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {Object} Selectors
|
||||||
|
* @property {Object} contactForm
|
||||||
|
* @property {() => HTMLInputElement} contactForm.emailElem
|
||||||
|
* @property {() => Element | null} contactForm.errorElem
|
||||||
|
* @property {() => HTMLInputElement} contactForm.nameElem
|
||||||
|
* @property {() => HTMLTextAreaElement} contactForm.messageElem
|
||||||
|
* @property {() => HTMLFormElement | null} contactForm.self
|
||||||
|
* @property {() => HTMLInputElement | null} contactForm.submitButton
|
||||||
|
* @property {Object} otpDialog
|
||||||
|
* @property {() => NodeListOf<HTMLInputElement>} otpDialog.allOtpInputs
|
||||||
|
* @property {() => Element | null} otpDialog.errorElem
|
||||||
|
* @property {() => HTMLInputElement | null} otpDialog.firstOtpInput
|
||||||
|
* @property {() => HTMLFormElement | null} otpDialog.otpForm
|
||||||
|
* @property {() => Element | null} otpDialog.otpRecipient
|
||||||
|
* @property {() => Element | null} otpDialog.otpValidUntil
|
||||||
|
* @property {() => HTMLButtonElement | null} otpDialog.resendButton
|
||||||
|
* @property {() => HTMLDialogElement} otpDialog.self
|
||||||
|
* @property {() => HTMLInputElement | null} otpDialog.submitButton
|
||||||
|
* @property {Object} successSection
|
||||||
|
* @property {() => Element | null} successSection.email
|
||||||
|
* @property {() => Element | null} successSection.message
|
||||||
|
* @property {() => Element | null} successSection.name
|
||||||
|
* @property {() => Element | null} successSection.self
|
||||||
|
*/
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { actions } from "./actions.js";
|
||||||
|
import { postErrorMessageOnContactForm } from "./post-error-message.js";
|
||||||
|
import { resetResendButton } from "./reset-resend-button.js";
|
||||||
|
|
||||||
|
/** @typedef {import("./selectors.js").Selectors} Selectors */
|
||||||
|
|
||||||
|
const fallbackErrorMsg =
|
||||||
|
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Selectors} selectors
|
||||||
|
* @param {NodeJS.Timeout | undefined} resetButtonInterval
|
||||||
|
* @returns {Promise<Result>}
|
||||||
|
*/
|
||||||
|
export async function submitContactForm(selectors, resendButtonInterval) {
|
||||||
|
const { contactForm, otpDialog } = selectors;
|
||||||
|
|
||||||
|
const name = contactForm.nameElem()?.value;
|
||||||
|
const email = contactForm.emailElem().value;
|
||||||
|
|
||||||
|
contactForm.submitButton()?.setAttribute("disabled", "");
|
||||||
|
try {
|
||||||
|
await actions.sendOtp({ type: "email", name, email });
|
||||||
|
} catch (sendOtpError) {
|
||||||
|
const errorMsg = sendOtpError?.toString() ?? fallbackErrorMsg;
|
||||||
|
postErrorMessageOnContactForm(selectors, errorMsg);
|
||||||
|
throw sendOtpError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpRecipient = otpDialog.otpRecipient();
|
||||||
|
email && otpRecipient && (otpRecipient.textContent = `<${email}>`);
|
||||||
|
const otpValidUntil = otpDialog.otpValidUntil();
|
||||||
|
const validUntil = new Date(Date.now() + 1000 * 60 * 5);
|
||||||
|
otpValidUntil &&
|
||||||
|
(otpValidUntil.textContent = `until ${validUntil.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`);
|
||||||
|
|
||||||
|
const dialog = otpDialog.self();
|
||||||
|
dialog.showModal();
|
||||||
|
contactForm.submitButton()?.removeAttribute("disabled");
|
||||||
|
dialog.addEventListener("click", function (event) {
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
const isInDialog =
|
||||||
|
rect.top <= event.clientY &&
|
||||||
|
event.clientY <= rect.top + rect.height &&
|
||||||
|
rect.left <= event.clientX &&
|
||||||
|
event.clientX <= rect.left + rect.width;
|
||||||
|
if (!isInDialog) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return resetResendButton(selectors, resendButtonInterval);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { actions } from "./actions.js";
|
||||||
|
import { postErrorMessageOnOtpForm } from "./post-error-message.js";
|
||||||
|
|
||||||
|
/** @typedef {import("./selectors.js").Selectors} Selectors */
|
||||||
|
|
||||||
|
const fallbackErrorMsg =
|
||||||
|
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Selectors} selectors
|
||||||
|
*/
|
||||||
|
export async function submitOtpForm(selectors) {
|
||||||
|
const { contactForm, otpDialog, successSection } = selectors;
|
||||||
|
const otpForm = otpDialog.otpForm();
|
||||||
|
otpDialog.submitButton()?.setAttribute("disabled", "");
|
||||||
|
|
||||||
|
const otpFormData = new FormData(otpForm ?? undefined);
|
||||||
|
const guess = [
|
||||||
|
otpFormData.get("1"),
|
||||||
|
otpFormData.get("2"),
|
||||||
|
otpFormData.get("3"),
|
||||||
|
otpFormData.get("4"),
|
||||||
|
otpFormData.get("5"),
|
||||||
|
otpFormData.get("6"),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const name = contactForm.nameElem()?.value;
|
||||||
|
const email = contactForm.emailElem().value;
|
||||||
|
const message = contactForm.messageElem()?.value;
|
||||||
|
|
||||||
|
let verifyResult;
|
||||||
|
try {
|
||||||
|
verifyResult = await actions.verifyOtp({ guess, userId: email });
|
||||||
|
} catch (verifyError) {
|
||||||
|
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||||
|
postErrorMessageOnOtpForm(
|
||||||
|
selectors,
|
||||||
|
verifyError?.toString() ?? fallbackErrorMsg,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyResult === false) {
|
||||||
|
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||||
|
postErrorMessageOnOtpForm(selectors, "Incorrect OTP. Check your email?");
|
||||||
|
otpDialog.firstOtpInput()?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sendmailToken = verifyResult;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actions.sendEmail({
|
||||||
|
email,
|
||||||
|
message: message,
|
||||||
|
name: name,
|
||||||
|
userId: email,
|
||||||
|
token: sendmailToken,
|
||||||
|
});
|
||||||
|
} catch (sendEmailError) {
|
||||||
|
const errorMsg = sendEmailError?.toString() ?? fallbackErrorMsg;
|
||||||
|
postErrorMessageOnOtpForm(selectors, errorMsg);
|
||||||
|
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentName = successSection.name();
|
||||||
|
const sentEmail = successSection.email();
|
||||||
|
const sentMessage = successSection.message();
|
||||||
|
sentName && (sentName.textContent = name ?? "???");
|
||||||
|
sentEmail && (sentEmail.textContent = email ?? "???");
|
||||||
|
sentMessage && (sentMessage.textContent = message ?? "???");
|
||||||
|
|
||||||
|
contactForm.self()?.remove();
|
||||||
|
successSection.self()?.removeAttribute("hidden");
|
||||||
|
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||||
|
otpDialog.self().close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
class OtpForm extends HTMLElement {
|
||||||
|
/** @type MutationObserver? */
|
||||||
|
observer;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.observer = new MutationObserver(() => {
|
||||||
|
this.clearInputs();
|
||||||
|
this.configureInputs();
|
||||||
|
});
|
||||||
|
this.observer.observe(this, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInputs() {
|
||||||
|
console.log("clearing all inputs");
|
||||||
|
/** @type NodeListOf<HTMLInputElement> */
|
||||||
|
const inputs = this.querySelectorAll('input:not([type="submit"])');
|
||||||
|
for (const input of inputs) {
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureInputs() {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
/** @type NodeListOf<HTMLInputElement> */
|
||||||
|
const inputs = this.querySelectorAll('input:not([type="submit"])');
|
||||||
|
/** @type NodeListOf<HTMLFormElement> */
|
||||||
|
const form = this.querySelector("form");
|
||||||
|
|
||||||
|
for (const input of inputs) {
|
||||||
|
input.addEventListener("focus", () => {
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
if (input.value.length > 0) {
|
||||||
|
input.value = input.value.slice(0, 1).toLocaleUpperCase();
|
||||||
|
/** @type HTMLInputElement */
|
||||||
|
const nextInput = input.nextElementSibling;
|
||||||
|
if (nextInput) {
|
||||||
|
nextInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type HTMLInputElement */
|
||||||
|
const submitButton = form.querySelector('input[type="submit"]');
|
||||||
|
submitButton.focus();
|
||||||
|
let areAllInputsEntered = true;
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
areAllInputsEntered = areAllInputsEntered && input.value.length > 0;
|
||||||
|
});
|
||||||
|
if (areAllInputsEntered) {
|
||||||
|
form.requestSubmit(submitButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("otp-form", OtpForm);
|
||||||
@@ -18,11 +18,6 @@
|
|||||||
<!-- Canonical URL -->
|
<!-- Canonical URL -->
|
||||||
<link rel="canonical" href={canonicalURL} />
|
<link rel="canonical" href={canonicalURL} />
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
|
||||||
<title>{title}</title>
|
|
||||||
<meta name="title" content={title} />
|
|
||||||
<meta name="description" content={description} />
|
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content={Astro.url} />
|
<meta property="og:url" content={Astro.url} />
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>${TITLE}</title>
|
||||||
|
<meta name="title" content="${TITLE}" />
|
||||||
|
<meta name="description" content="${DESCRIPTION}" />
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/blog">Blog</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/microlog">Microlog</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/contact">Contact</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script src="/scripts/otp-form-wc.js"></script>
|
||||||
|
<link rel="stylesheet" href="/css/otp.css" />
|
||||||
|
|
||||||
|
<dialog class="otp-dialog">
|
||||||
|
<otp-form>
|
||||||
|
<form class="otp-form" class="otp-form">
|
||||||
|
<p>
|
||||||
|
I've sent a six-digit code to <span class="otp-recipient">your email address</span>.
|
||||||
|
Let me know what it is, so I can confirm this really is your email address. The
|
||||||
|
code will be valid <span class="otp-valid-until">for five minutes</span>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p hidden class="error"/>
|
||||||
|
|
||||||
|
<div class="otp-inputs">
|
||||||
|
<input maxlength="1" name="1" required autocapitalize="characters" autocomplete="one-time-code" aria-label="First digit of one-time passcode" autofocus>
|
||||||
|
<input maxlength="1" name="2" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Second digit of one-time passcode">
|
||||||
|
<input maxlength="1" name="3" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Third digit of one-time passcode">
|
||||||
|
<input maxlength="1" name="4" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Fourth digit of one-time passcode">
|
||||||
|
<input maxlength="1" name="5" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Fifth digit of one-time passcode">
|
||||||
|
<input maxlength="1" name="6" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Sixth digit of one-time passcode">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="submit" value="Verify">
|
||||||
|
<button disabled class="resend-button">Resend (60s)</button>
|
||||||
|
</form>
|
||||||
|
</otp-form>
|
||||||
|
</dialog>
|
||||||
@@ -8,6 +8,9 @@ pp "${SHARE}"/components/head.upphtml
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
#!
|
||||||
|
pp "${SHARE}"/components/navbar.upphtml
|
||||||
|
#!
|
||||||
<main>
|
<main>
|
||||||
#!
|
#!
|
||||||
pp "$1"
|
pp "$1"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='UTF-8'?>
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>
|
<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>
|
||||||
#!
|
#!
|
||||||
for f in ${OUT}/*.html
|
for f in $(find "${OUT}" -name "*.html")
|
||||||
do
|
do
|
||||||
#!
|
#!
|
||||||
<url>
|
<url>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!
|
||||||
|
export TITLE="Contact"
|
||||||
|
export DESCRIPTION="Contact Joe Carstairs"
|
||||||
|
pp "${SHARE}"/components/meta.upphtml
|
||||||
|
#!
|
||||||
|
|
||||||
|
#!
|
||||||
|
pp "${SHARE}"/components/otp-dialog.upphtml
|
||||||
|
#!
|
||||||
|
|
||||||
|
<form class=contact-form>
|
||||||
|
<h1>Contact me</h1>
|
||||||
|
<p hidden class=error></p>
|
||||||
|
<label for=name>Name</label>
|
||||||
|
<input id=name name=name type=text required>
|
||||||
|
<label for=email>Email</label>
|
||||||
|
<input id=email name=email type=email required>
|
||||||
|
<label for=message>Message</label>
|
||||||
|
<textarea id=message name=message required></textarea>
|
||||||
|
<input type=submit value=Send>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class=success hidden=true>
|
||||||
|
<h1>You sent me a message!</h1>
|
||||||
|
<p>Thanks for that. I may be in touch.</p>
|
||||||
|
<p>In case you forgot, your message was this:</p>
|
||||||
|
<hr>
|
||||||
|
<dl>
|
||||||
|
<dt>Name</dt> <dd class=sentname>???</dd>
|
||||||
|
<dt>Email</dt> <dd class=sentemail>???</dd>
|
||||||
|
<dt>Message</dt> <dd class=sentmessage>???</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script type=module>
|
||||||
|
import { resendOtp } from '/scripts/contact/resend-otp.js';
|
||||||
|
import { submitContactForm } from '/scripts/contact/submit-contact-form.js';
|
||||||
|
import { submitOtpForm } from '/scripts/contact/submit-otp-form.js';
|
||||||
|
|
||||||
|
function locateOrPanic(selector, desc, root) {
|
||||||
|
const elem = (root ?? document).querySelector(selector);
|
||||||
|
if (!elem) {
|
||||||
|
alert('Technical error: could not locate ' + desc + '. Please let Joe know if you have another means of contacting him.');
|
||||||
|
throw new Error('Could not locate ' + desc);
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactForm = locateOrPanic('form.contact-form', 'contact form');
|
||||||
|
const otpDialog = locateOrPanic('dialog.otp-dialog', 'OTP dialog');
|
||||||
|
const otpForm = locateOrPanic('form.otp-form', 'OTP form');
|
||||||
|
const successSection = document.querySelector('section.success');
|
||||||
|
let resendButtonInterval = undefined;
|
||||||
|
|
||||||
|
const selectors = {
|
||||||
|
contactForm: {
|
||||||
|
emailElem: () => locateOrPanic('input[name=email]', 'email input', contactForm),
|
||||||
|
errorElem: () => contactForm.querySelector('.error'),
|
||||||
|
nameElem: () => locateOrPanic('input[name=name]', 'name input', contactForm),
|
||||||
|
messageElem: () => locateOrPanic('textarea[name=message]', 'message textarea', contactForm),
|
||||||
|
self: () => contactForm,
|
||||||
|
submitButton: () => contactForm.querySelector('input[type=submit]'),
|
||||||
|
},
|
||||||
|
otpDialog: {
|
||||||
|
allOtpInputs: () => otpForm.querySelectorAll('input:not([type=submit])'),
|
||||||
|
errorElem: () => otpDialog.querySelector('.error'),
|
||||||
|
firstOtpInput: () => otpForm.querySelector('input:not([type=submit]):first-child'),
|
||||||
|
otpForm: () => otpForm,
|
||||||
|
otpRecipient: () => otpDialog.querySelector('.otp-recipient'),
|
||||||
|
otpValidUntil: () => otpDialog.querySelector('.otp-valid-until'),
|
||||||
|
resendButton: () => otpDialog.querySelector('button.resend-button'),
|
||||||
|
self: () => otpDialog,
|
||||||
|
submitButton: () => otpForm.querySelector('input[type=submit]'),
|
||||||
|
},
|
||||||
|
successSection: {
|
||||||
|
email: () => successSection?.querySelector('.sentemail') ?? null,
|
||||||
|
name: () => successSection?.querySelector('.sentname') ?? null,
|
||||||
|
message: () => successSection?.querySelector('.sentmessage') ?? null,
|
||||||
|
self: () => successSection,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
contactForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
({ resendButtonInterval } = await submitContactForm(selectors, resendButtonInterval));
|
||||||
|
});
|
||||||
|
|
||||||
|
selectors.otpDialog.resendButton()?.addEventListener('click', async () => {
|
||||||
|
({ resendButtonInterval } = await resendOtp(selectors, resendButtonInterval));
|
||||||
|
});
|
||||||
|
|
||||||
|
otpForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await submitOtpForm(selectors);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -114,7 +114,7 @@ body {
|
|||||||
|
|
||||||
/* Geometric Humanist stack from https://modernfontstacks.com */
|
/* Geometric Humanist stack from https://modernfontstacks.com */
|
||||||
font-family:
|
font-family:
|
||||||
Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif;
|
Avenir, Montserrat, Corbel, 'URW Gothic', source-sans-pro, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
@@ -442,11 +442,11 @@ form {
|
|||||||
margin-block-end: var(--spacing-block-xs);
|
margin-block-end: var(--spacing-block-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
* + :is(label, input[type="submit"], button) {
|
* + :is(label, input[type='submit'], button) {
|
||||||
margin-block-start: var(--spacing-block-md);
|
margin-block-start: var(--spacing-block-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(input[type="submit"], input[type="button"], button) {
|
:is(input[type='submit'], input[type='button'], button) {
|
||||||
padding-inline: var(--spacing-inline-sm);
|
padding-inline: var(--spacing-inline-sm);
|
||||||
max-width: max-content;
|
max-width: max-content;
|
||||||
&:not(dialog *) {
|
&:not(dialog *) {
|
||||||
@@ -454,7 +454,7 @@ form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(input[type="email"], input[type="text"], select) {
|
:is(input[type='email'], input[type='text'], select) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 16rem;
|
width: 16rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.h-feed :is(a.full-feed-link, .full-feed-link a)::after {
|
.h-feed :is(a.full-feed-link, .full-feed-link a)::after {
|
||||||
content: " >";
|
content: ' >';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
top: -6rem;
|
top: -6rem;
|
||||||
|
|
||||||
/* A content value is needed to get the ::after to render */
|
/* A content value is needed to get the ::after to render */
|
||||||
content: "";
|
content: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 36rem) {
|
@media (min-width: 36rem) {
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
grid-template-columns: subgrid; /** Subgrid of main column layout */
|
grid-template-columns: subgrid; /** Subgrid of main column layout */
|
||||||
grid-template-rows: min-content 1fr;
|
grid-template-rows: min-content 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"empty heading"
|
'empty heading'
|
||||||
"photo text";
|
'photo text';
|
||||||
column-gap: var(--spacing-block-sm);
|
column-gap: var(--spacing-block-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="submit"] {
|
input[type='submit'] {
|
||||||
max-width: max-content;
|
max-width: max-content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
|
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
|
||||||
ul[role="list"],
|
ul[role='list'],
|
||||||
ol[role="list"] {
|
ol[role='list'] {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!
|
||||||
|
export TITLE="Got lost?"
|
||||||
|
export DESCRIPTION="Error page for Joe Carstairs' personal website"
|
||||||
|
pp "${SHARE}"/components/meta.upphtml
|
||||||
|
#!
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1>Got lost?</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you’re on this page, something’s probably gone wrong. Try going to
|
||||||
|
my <a href="/">homepage</a> instead.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
+47
-1
@@ -1 +1,47 @@
|
|||||||
hello, world
|
#!
|
||||||
|
export TITLE="Joe Carstairs"
|
||||||
|
export DESCRIPTION="Joe Carstairs' personal website"
|
||||||
|
pp "${SHARE}"/components/meta.upphtml
|
||||||
|
#!
|
||||||
|
|
||||||
|
<section class="h-card">
|
||||||
|
<div>
|
||||||
|
<img class="u-photo" src="/images/headshot.webp" height="96" width="96" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Joe Carstairs</h1>
|
||||||
|
<div hidden>
|
||||||
|
<a class="p-name u-url u-uid" href="https://joeac.net" rel="me">
|
||||||
|
Permalink
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="h-card__text">
|
||||||
|
<p>
|
||||||
|
Hi! 👋 My name is <span class="p-given-name">Joe</span>
|
||||||
|
<span class="p-family-name">Carstairs</span>. I’m a
|
||||||
|
<span class="p-job-title">software developer</span> at
|
||||||
|
<a class="p-org" href="https://www.scottlogic.com">Scott Logic</a>,
|
||||||
|
a Divinity student at the University of Edinburgh,
|
||||||
|
a lapsed fiddle player and a very occasional poet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
HMU with your thoughts on Elizabethan poetry, connecting to the
|
||||||
|
Internet, and the Apocalypse of Saint John.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<small>
|
||||||
|
Or get me on
|
||||||
|
<a href="https://www.facebook.com/joe.carstairs.5" rel="me">Facebook</a>,
|
||||||
|
<a href="https://mastodon.social/@joe_carstairs" rel="me">Mastodon</a>,
|
||||||
|
<a href="https://www.linkedin.com/in/joe-carstairs-0aa936277" rel="me">LinkedIn</a>,
|
||||||
|
<a href="https://bsky.app/profile/joeacarstairs.bsky.social" rel="me">BlueSky</a>,
|
||||||
|
or <a href="https://github.com/joeacarstairs" rel="me">GitHub</a>.
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user