Compare commits

..

22 Commits

Author SHA1 Message Date
joeac 2d6d17261c http: adds contact page 2026-06-04 15:16:34 +01:00
joeac 668c105ccf http: adds otp-dialog component 2026-06-04 15:16:34 +01:00
joeac 813836fbba http: adds scripts 2026-06-04 15:16:34 +01:00
joeac fac1199cd6 http: adds actions.js file to interface JS -> PHP 2026-06-04 15:16:34 +01:00
joeac 171c957c40 http: adds php api routes under do/ 2026-06-04 15:16:34 +01:00
joeac 231ffbd0e8 adds mail.php 2026-06-04 15:14:03 +01:00
joeac e4cd434586 adds db.php 2026-06-04 15:12:26 +01:00
joeac 38370c101f http: adds phpmailer dependency 2026-06-04 15:11:10 +01:00
joeac a89e757718 http: inits php composer 2026-06-04 15:10:45 +01:00
joeac e58cbf7b72 http: adds error page 2026-06-02 18:30:12 +01:00
joeac 902ad835b4 http: fixes meta tags 2026-06-02 18:27:20 +01:00
joeac 3f4df7c503 updates index.upphtml to match existing homepage 2026-06-02 18:12:44 +01:00
joeac a304b1f744 changes " to ' in uppcss files to work around bug in pp 2026-06-02 18:12:29 +01:00
joeac 94e5dfd091 http: add navbar to l.upphtml 2026-06-02 18:07:43 +01:00
joeac bdd64372fd adds images symlink to http/public 2026-06-02 18:02:14 +01:00
joeac ec2cd858bd not sure what this change is, maybe a chmod on mkws? 2026-06-02 18:00:37 +01:00
joeac 6bc4552d41 mkws copies from public if it exists 2026-06-02 18:00:14 +01:00
joeac 86f40be2cb mkws outputs to 'out' instead of 'public' 2026-06-02 17:59:45 +01:00
joeac 7ac7ed3cec feed.uppcss: changes " to ' to work around pp bug 2026-06-02 17:50:16 +01:00
joeac a1866254f9 sitemap.uppxml: uses find instead of wildcard expansion to find html files inside
subfolders
2026-06-02 17:45:39 +01:00
joeac b7fc80b5f5 http/Makefile: public target depends on bin 2026-06-02 17:35:19 +01:00
joeac 629dd5525e http/Makefile: all cleans before building 2026-06-02 17:35:04 +01:00
35 changed files with 1335 additions and 25 deletions
Executable → Regular
+9 -1
View File
@@ -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
View File
@@ -1 +1,2 @@
public
out
vendor
+3 -3
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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"
}
]
}
+101
View File
@@ -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"
}
+60
View File
@@ -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
View File
@@ -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);
}
+35
View File
@@ -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();
}
+124
View File
@@ -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;
}
+104
View File
@@ -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;
}
+63
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
../../common/images/
+60
View File
@@ -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 = "";
}
}
+31
View File
@@ -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 };
}
+25
View File
@@ -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();
}
+68
View File
@@ -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);
-5
View File
@@ -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} />
+4
View File
@@ -0,0 +1,4 @@
<!-- Primary Meta Tags -->
<title>${TITLE}</title>
<meta name="title" content="${TITLE}" />
<meta name="description" content="${DESCRIPTION}" />
+16
View File
@@ -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>
+28
View File
@@ -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>
+3
View File
@@ -8,6 +8,9 @@ pp "${SHARE}"/components/head.upphtml
</head>
<body>
#!
pp "${SHARE}"/components/navbar.upphtml
#!
<main>
#!
pp "$1"
+1 -1
View File
@@ -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>
+96
View File
@@ -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>
+4 -4
View File
@@ -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;
}
+1 -1
View File
@@ -32,5 +32,5 @@
}
.h-feed :is(a.full-feed-link, .full-feed-link a)::after {
content: " >";
content: ' >';
}
+3 -3
View File
@@ -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);
}
+1 -1
View File
@@ -23,7 +23,7 @@
margin: 0;
}
input[type="submit"] {
input[type='submit'] {
max-width: max-content;
}
}
+2 -2
View File
@@ -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;
}
+14
View File
@@ -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 youre on this page, somethings probably gone wrong. Try going to
my <a href="/">homepage</a> instead.
</p>
</section>
+47 -1
View File
@@ -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>. Im 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>