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=
|
||||
|
||||
# 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=
|
||||
|
||||
|
||||
+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
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf public
|
||||
rm -rf out
|
||||
|
||||
+11
-2
@@ -9,9 +9,18 @@ usage() {
|
||||
test $# -lt 1 && usage
|
||||
|
||||
export SRC="$(realpath src)"
|
||||
export PUBLIC="$(realpath public)"
|
||||
export SHARE="$(realpath share)"
|
||||
export OUT="$(realpath public)"
|
||||
mkdir -p ${OUT}
|
||||
export OUT="$(realpath 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
|
||||
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 -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<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>
|
||||
|
||||
<body>
|
||||
#!
|
||||
pp "${SHARE}"/components/navbar.upphtml
|
||||
#!
|
||||
<main>
|
||||
#!
|
||||
pp "$1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>
|
||||
#!
|
||||
for f in ${OUT}/*.html
|
||||
for f in $(find "${OUT}" -name "*.html")
|
||||
do
|
||||
#!
|
||||
<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 */
|
||||
font-family:
|
||||
Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif;
|
||||
Avenir, Montserrat, Corbel, 'URW Gothic', source-sans-pro, sans-serif;
|
||||
}
|
||||
|
||||
small {
|
||||
@@ -442,11 +442,11 @@ form {
|
||||
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);
|
||||
}
|
||||
|
||||
:is(input[type="submit"], input[type="button"], button) {
|
||||
:is(input[type='submit'], input[type='button'], button) {
|
||||
padding-inline: var(--spacing-inline-sm);
|
||||
max-width: max-content;
|
||||
&: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%;
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
@@ -32,5 +32,5 @@
|
||||
}
|
||||
|
||||
.h-feed :is(a.full-feed-link, .full-feed-link a)::after {
|
||||
content: " >";
|
||||
content: ' >';
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
top: -6rem;
|
||||
|
||||
/* A content value is needed to get the ::after to render */
|
||||
content: "";
|
||||
content: '';
|
||||
}
|
||||
|
||||
@media (min-width: 36rem) {
|
||||
@@ -37,8 +37,8 @@
|
||||
grid-template-columns: subgrid; /** Subgrid of main column layout */
|
||||
grid-template-rows: min-content 1fr;
|
||||
grid-template-areas:
|
||||
"empty heading"
|
||||
"photo text";
|
||||
'empty heading'
|
||||
'photo text';
|
||||
column-gap: var(--spacing-block-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="submit"] {
|
||||
input[type='submit'] {
|
||||
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 */
|
||||
ul[role="list"],
|
||||
ol[role="list"] {
|
||||
ul[role='list'],
|
||||
ol[role='list'] {
|
||||
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