Compare commits

...

49 Commits

Author SHA1 Message Date
beb9a26512 updates README with new Podman instructions 2026-01-09 10:01:42 +00:00
00f793c53b Revert "WIP sign on contact form"
This reverts commit 9f85d2f1b3.
2026-01-09 09:27:36 +00:00
9f85d2f1b3 WIP sign on contact form 2026-01-08 22:30:25 +00:00
d8d2e94008 does nothing when compiling image assets 2026-01-08 22:16:58 +00:00
d9824e60a5 Revert "manually installs sharp"
This reverts commit 3966da7bc5.
2026-01-08 22:16:11 +00:00
3966da7bc5 manually installs sharp 2026-01-08 22:05:03 +00:00
48ca4c2a03 fixes SMTP client config in website 2026-01-08 21:13:38 +00:00
bde6f2a253 fixes env and args in website Dockerfile 2026-01-08 21:13:09 +00:00
8e00726b04 remove TODO comment block 2026-01-08 21:12:16 +00:00
e0170e82aa fixes SMTP server 2026-01-08 21:10:08 +00:00
64f2092161 website uses smtp 2025-12-18 21:08:04 +00:00
ba4b4ea980 package-lock.json updated from move 2025-12-18 21:07:49 +00:00
6592d49165 moves tsconfig.json to website/ 2025-12-18 21:06:48 +00:00
2fdf12259c add smtp service 2025-12-18 20:52:19 +00:00
e568105b99 website image uses alpine linux 2025-12-18 17:01:26 +00:00
a1304c5afd website image uses alpine linux 2025-12-18 16:58:36 +00:00
c9f7ad699c infra is not on DigitalOcean 2025-12-18 16:41:57 +00:00
20effac610 add compose.yaml 2025-12-18 16:41:00 +00:00
9c50e74904 Move website root to website/ 2025-12-18 16:29:06 +00:00
b0be9fae2f improve form styles in dialog 2025-12-18 13:13:48 +00:00
8f95c6ff74 WIP: Dockerise with sendmail 2025-12-18 13:13:34 +00:00
9d8d2a266a add !package-* to dockerignore 2025-12-18 13:13:12 +00:00
6c268a5548 ignore SQLite files 2025-12-18 11:28:18 +00:00
50f4d52317 dockerignore .env files 2025-12-18 11:06:13 +00:00
46c9b77316 contact page 2025-12-18 11:04:03 +00:00
40d6c7f248 otp actions 2025-12-18 11:03:32 +00:00
a81d1de1e5 sendmail action 2025-12-18 11:03:12 +00:00
46387b41ce updates styles 2025-12-18 11:00:24 +00:00
476fe39f50 define OtpDialog 2025-12-18 10:59:46 +00:00
a85b7b36c6 adds SentEmails and SendmailToken tables to DB 2025-12-18 10:59:15 +00:00
1d83c50e27 adds MAX_DAILY_EMAILS env var 2025-12-18 10:58:52 +00:00
fcb297637d adds --remote to build command 2025-12-18 10:58:34 +00:00
781af6414e resets hidden things to display:none 2025-12-16 16:57:49 +00:00
35f391d933 forms are sections for layout purposes 2025-12-16 16:57:36 +00:00
22ce9d06e2 hr is 2px high 2025-12-16 16:57:22 +00:00
9d610b6b3d reformat base.css 2025-12-16 16:57:13 +00:00
0da1f8710e adds SENDMAIL_BIN env var 2025-12-16 16:56:36 +00:00
e119eb62e8 install @types/nodemailer 2025-12-16 14:22:37 +00:00
9dab11d615 remove housewarming notice 2025-12-16 13:17:37 +00:00
fe69e75001 install nodemailer 2025-12-16 13:15:46 +00:00
508db104f2 update package-lock.json 2025-12-16 13:14:23 +00:00
e0fad33706 Revert "very"
This reverts commit ff8201c4f3.
2025-12-15 22:55:24 +00:00
4ac7f6af8e Merge branch 'main' of https://git.joeac.net/joeac/joeac.net 2025-12-15 22:29:49 +00:00
ff8201c4f3 very 2025-12-15 22:29:43 +00:00
b0161b6ddc remove rpi.sh 2025-12-15 20:52:16 +00:00
9458062b77 update bio 2025-12-15 20:50:17 +00:00
1b1d0eddf8 rpi.sh 2025-12-13 21:56:00 +00:00
7300b15421 rpi.sh 2025-12-13 20:38:40 +00:00
fa8948da13 run instructions 2025-12-12 14:47:04 +00:00
43 changed files with 3487 additions and 863 deletions

View File

@@ -1,5 +0,0 @@
node_modules/
dist/
.helix/
.vscode/
.zed/

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ pnpm-debug.log*
**/*.tfvars **/*.tfvars
**/*.tfstate **/*.tfstate
**/*.tfstate.backup **/*.tfstate.backup
*.sqlite

View File

@@ -1,12 +0,0 @@
FROM node:lts AS runtime
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./website/dist/server/entry.mjs"]

View File

@@ -4,24 +4,41 @@ Joe Carstairs' personal website
Structure: Structure:
├website: My public-facing website ```
└infrastructure: The infrastructure of my website as code /
├── website: My public-facing website
└── infrastructure: The infrastructure of my website as code
```
## Infrastructure ## Running with Podman
The infrastructure is on DigitalOcean. These instructions will probably work with Docker, too: just substitute `podman`
for `docker` in all the commands.
The website is hosted using the App Platform service from DigitalOcean. This is To run with Podman, first set up your environment variables. Copy `example.env`
free for static websites, and is quite flexible to add in extra apps as Droplets to `.env` and edit the values accordingly.
or Functions at a later time if I so please.
DigitalOcean App Platform re-deploys the website every time there's an update to Then, create the `remote_smtp_password` secret, storing the password for the
the `main` branch in this repo. remote SMTP server which will send the contact emails on behalf of the website.
All the DigitalOcean infrastructure is managed using Terraform. The code for ```bash
this is in the `infrastructure/` directory. sudo podman secret create remote_smtp_password /path/to/remote/smtp/password
```
The domain, however, is registered on AWS. The nameservers registered in AWS Now build and start the containers:
have to be kept manually up-to-date with the DigitalOcean nameservers. These
shouldn't change, though, so this is unlikely to need intervention more than ```bash
once. sudo podman-compose build && sudo podman-compose up -d
```
## Running on the host machine
To run on the host machine, first, as before, set up your environment variables
by copying `example.env` to `.env` and editing the values as appropriate.
```bash
npm run start
```
Note that emails may not work locally without further setup. These instructions
are of course woefully incomplete.

30
compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
website:
build:
context: website
env_file:
- .env
environment:
LOCAL_SMTP_HOST: smtp
LOCAL_SMTP_PORT: 2500
LOCAL_SMTP_PASSWORD: smtp
ports:
- "8000:4321"
smtp:
build:
context: smtp
args:
LOCAL_SMTP_PORT: 2500
env_file:
- .env
environment:
LOCAL_SMTP_PORT: 2500
LOCAL_SMTP_PASSWORD: smtp
REMOTE_SMTP_PASSWORD_FILE: /run/secrets/remote_smtp_password
secrets:
- remote_smtp_password
secrets:
remote_smtp_password:
external: true

20
example.env Executable file
View File

@@ -0,0 +1,20 @@
# The username for authenticating to the local SMTP server
# Recommended to be the same as REMOTE_SMTP_USER, else the remote may reject it
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
LOCAL_SMTP_ENVELOPE_FROM=
# The host of the remote SMTP server, e.g. smtp.gmail.com
REMOTE_SMTP_HOST=
# The port of the remote SMTP server: usually 25, 465, or 587
REMOTE_SMTP_PORT=
# The username for authenticating to the remote SMTP server
# Usually the email address which will be sending the contact emails
REMOTE_SMTP_USER=me@joeac.net
# The email address where contact emails will be sent
CONTACT_MAILBOX=me@joeac.net

View File

@@ -1,26 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/digitalocean/digitalocean" {
version = "2.38.0"
constraints = "~> 2.0"
hashes = [
"h1:ElG5GAiHN6paMNUG0JnQ4uCv1Y37ZUbCXQSAQwb/j8U=",
"zh:04d1ca6ac6d7e69635657aeac8aadb75f84018305514381f9d7bed48065df61b",
"zh:081258f8526b3597eeb7154d5b453c2fe36194ca1a95e0de655d7a1080224be0",
"zh:15e77088b8a73012d87ad29ad3cf10392642a6781a35c0923a45386fe61cda61",
"zh:1a0c741f9be0f22c18fec93b4200c1968c1c6e4ac02292d494ace78b16f13fea",
"zh:2a3a778cd982e5e09a2414f0a16c54101c56d7ab6f824ea3e55709a608f7f3ea",
"zh:6832b594a7e408e085bf148c5a1e2eaf94ed8f47796b917acf56078ee9362ca1",
"zh:78b8acad99e7035344677f70c08c3daf457cfa3f8b467e73352d14353889b8cf",
"zh:8a1c446f1b3b5dee097fc000cf4d341b00602ace60246af4a09e5a49666a0638",
"zh:8c23094d06f5b4c780dbdde21c56b9b03b275c1cc8728669ef8f3f45e0d9fb24",
"zh:8c5a9871c2a2e094f58723624bb3fd2dbedafc8d9cab609df79d01011de79b8a",
"zh:a32adff1d0fc405f1596b6dc679b3029c87177e717eb991c418fff13ec559427",
"zh:a3f89176401db06ccc97b89a23bb004c8ab1562ce04178d6a08125bd4b8c073e",
"zh:d739de876cdd6176570c610e0edca0d2133b8e21787f22caa9a72d9806055c07",
"zh:d8a874a0e442c10deae3217bb88375209d45f79d52cb7d2b19756863d6ab6414",
"zh:eac47cd0b28953404622d11d8d4049ce2a4da3bc0a0ecaf386e5f55117386bbd",
"zh:ec3686163d1177d41f13588bb24db9a2b3afd199ac410d8c9899f053f271515c",
]
}

View File

@@ -1,53 +0,0 @@
resource "digitalocean_app" "joeac_net" {
project_id = "c106269c-1115-4682-8757-867368e057a4"
spec {
name = "joeac-net"
region = local.region
domain {
name = local.domain
}
features = [
"buildpack-stack=ubuntu-22"
]
alert {
disabled = false
rule = "DEPLOYMENT_FAILED"
}
alert {
disabled = false
rule = "DOMAIN_FAILED"
}
ingress {
rule {
component {
name = "personal-website"
preserve_path_prefix = false
}
match {
path {
prefix = "/"
}
}
}
}
static_site {
build_command = "npm run build"
environment_slug = "node-js"
name = "personal-website"
output_dir = "website/dist"
source_dir = "/"
github {
branch = "main"
deploy_on_push = true
repo = "joeacarstairs/personal-website"
}
}
}
}

View File

@@ -1,5 +0,0 @@
locals {
domain = "joeac.net"
region = "lon"
}

View File

@@ -1,9 +0,0 @@
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
}
}

View File

@@ -1,4 +0,0 @@
provider "digitalocean" {
token = var.do_token
}

View File

@@ -1,5 +0,0 @@
variable "do_token" {
type = string
sensitive = true
description = "A DigitalOcean access token. Can also be provided as an environment variable"
}

12
smtp/.msmtprc Normal file
View File

@@ -0,0 +1,12 @@
defaults
auth on
tls on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile /var/msmtp/msmtp.log
account default
eval echo from "$LOCAL_SMTP_ENVELOPE_FROM"
eval echo host "$REMOTE_SMTP_HOST"
eval echo port "$REMOTE_SMTP_PORT"
eval echo user "$REMOTE_SMTP_USER"
passwordeval cat "$REMOTE_SMTP_PASSWORD_FILE"

23
smtp/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM alpine:3.22
WORKDIR /
RUN mkdir -p /var/msmtp
RUN apk --update --no-cache add git autoconf automake build-base gettext gettext-dev gnutls-dev libtool make texinfo && \
git clone https://github.com/marlam/msmtp.git --branch msmtp-1.8.32 --single-branch --depth 1
WORKDIR /msmtp
RUN autoreconf -fi && \
./configure && \
make && \
make install
ARG LOCAL_SMTP_PORT
EXPOSE $LOCAL_SMTP_PORT
COPY .msmtprc ./
CMD msmtpd \
--auth=$LOCAL_SMTP_USER,'echo $LOCAL_SMTP_PASSWORD' \
--command='msmtp -C .msmtprc -f %F --' \
--interface=0.0.0.0 \
--log=/var/msmtp/msmtpd.log \
--port=$LOCAL_SMTP_PORT

7
website/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.astro/
dist/
node_modules/
.dockerignore
.env
*.sqlite
Dockerfile

22
website/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:lts-alpine3.22 AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY astro.config.mjs ./
COPY db ./db/
ARG DB_URL=file:/app/db.sqlite
ENV ASTRO_DB_REMOTE_URL=$DB_URL
RUN mkdir -p "$(dirname "$(echo "$ASTRO_DB_REMOTE_URL" | cut -d':' -f 2)")"
RUN npm run astro db push
COPY . .
RUN npm run build
ENV HOST=0.0.0.0
ENV PORT=4321
ENV MAX_DAILY_EMAILS=100
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "astro/config"; import { defineConfig, envField, passthroughImageService } from "astro/config";
import db from "@astrojs/db";
import mdx from "@astrojs/mdx"; import mdx from "@astrojs/mdx";
import node from "@astrojs/node"; import node from "@astrojs/node";
@@ -9,6 +10,29 @@ export default defineConfig({
adapter: node({ adapter: node({
mode: "standalone", mode: "standalone",
}), }),
env: {
schema: {
MAX_DAILY_EMAILS: envField.number({
context: "server",
access: "secret",
}),
LOCAL_SMTP_ENVELOPE_FROM: envField.string({
context: "server",
access: "secret",
}),
LOCAL_SMTP_HOST: envField.string({ context: "server", access: "secret" }),
LOCAL_SMTP_PORT: envField.number({ context: "server", access: "secret" }),
LOCAL_SMTP_USER: envField.string({ context: "server", access: "secret" }),
LOCAL_SMTP_PASSWORD: envField.string({
context: "server",
access: "secret",
}),
CONTACT_MAILBOX: envField.string({ context: "server", access: "secret" }),
},
},
image: {
service: passthroughImageService(),
},
site: "https://joeac.net", site: "https://joeac.net",
integrations: [mdx(), sitemap()], integrations: [db(), mdx(), sitemap()],
}); });

34
website/db/config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { column, defineDb, defineTable } from "astro:db";
const Otp = defineTable({
columns: {
userId: column.text(),
value: column.text(),
createdAt: column.number(),
validUntil: column.number(),
},
});
const SendmailToken = defineTable({
columns: {
userId: column.text(),
value: column.text(),
createdAt: column.number(),
validUntil: column.number(),
},
});
const SentEmails = defineTable({
columns: {
messageId: column.text(),
sentAt: column.number(),
},
});
export default defineDb({
tables: {
Otp,
SendmailToken,
SentEmails,
},
});

3
website/db/seed.ts Normal file
View File

@@ -0,0 +1,3 @@
import { db } from "astro:db";
export default async function seed() {}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,25 @@
{ {
"private": "true", "private": "true",
"scripts": { "scripts": {
"dev": "cd website && astro dev", "dev": "astro dev",
"start": "cd website && astro dev", "start": "astro build --remote && node ./dist/server/entry.mjs",
"build": "cd website && astro build", "build": "astro build --remote",
"preview": "cd cd website && webiste && astro preview", "preview": "astro preview",
"astro": "cd website && astro" "astro": "astro"
}, },
"engines": { "engines": {
"node": "^22.14.0" "node": "^22.14.0"
}, },
"dependencies": { "dependencies": {
"@astrojs/db": "^0.18.3",
"@astrojs/mdx": "^4.3.0", "@astrojs/mdx": "^4.3.0",
"@astrojs/node": "^9.5.1", "@astrojs/node": "^9.5.1",
"@astrojs/rss": "^4.0.10", "@astrojs/rss": "^4.0.10",
"@astrojs/sitemap": "^3.2.1", "@astrojs/sitemap": "^3.2.1",
"@types/nodemailer": "^7.0.4",
"astro": "^5.1.1", "astro": "^5.1.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nodemailer": "^7.0.11",
"typescript": "^5.4.3" "typescript": "^5.4.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -24,12 +24,39 @@
--colour-hyperlink-90: #bfe3ff; --colour-hyperlink-90: #bfe3ff;
--colour-hyperlink-95: #e0f1ff; --colour-hyperlink-95: #e0f1ff;
--colour-grey-10: oklch(0.1 0.01 84);
--colour-grey-20: oklch(0.2 0.01 84);
--colour-grey-30: oklch(0.3 0.01 84);
--colour-grey-40: oklch(0.4 0.01 84);
--colour-grey-50: oklch(0.5 0.01 84);
--colour-grey-60: oklch(0.6 0.01 84);
--colour-grey-70: oklch(0.7 0.01 84);
--colour-grey-80: oklch(0.8 0.01 84);
--colour-grey-90: oklch(0.9 0.01 84);
--colour-grey-95: oklch(0.95 0.01 84);
--colour-error-10: oklch(0.1 0.2 26);
--colour-error-20: oklch(0.2 0.2 26);
--colour-error-30: oklch(0.3 0.2 26);
--colour-error-40: oklch(0.4 0.2 26);
--colour-error-50: oklch(0.5 0.2 26);
--colour-error-60: oklch(0.6 0.2 26);
--colour-error-70: oklch(0.7 0.2 26);
--colour-error-80: oklch(0.8 0.2 26);
--colour-error-90: oklch(0.9 0.2 26);
--colour-error-95: oklch(0.95 0.2 26);
--colour-primary-fg: var(--colour-primary-90); --colour-primary-fg: var(--colour-primary-90);
--colour-primary-fg-accent: var(--colour-primary-80); --colour-primary-fg-accent: var(--colour-primary-80);
--colour-primary-bg: var(--colour-primary-10); --colour-primary-bg: var(--colour-primary-10);
--colour-primary-bg-accent: var(--colour-primary-20);
--colour-code-fg: var(--colour-primary-90); --colour-code-fg: var(--colour-primary-90);
--colour-code-bg: var(--colour-primary-15); --colour-code-bg: var(--colour-primary-15);
--colour-hyperlink: var(--colour-hyperlink-80); --colour-hyperlink: var(--colour-hyperlink-80);
--colour-grey-fg: var(--colour-grey-70);
--colour-grey-bg: var(--colour-grey-30);
--colour-error-fg: var(--colour-error-90);
--colour-error-bg: var(--colour-error-40);
--font-size-sm: 1rem; --font-size-sm: 1rem;
--font-size-base: 1.125rem; --font-size-base: 1.125rem;
@@ -56,6 +83,10 @@
--colour-primary-fg-accent: var(--colour-primary-40); --colour-primary-fg-accent: var(--colour-primary-40);
--colour-primary-bg: var(--colour-primary-95); --colour-primary-bg: var(--colour-primary-95);
--colour-hyperlink: var(--colour-hyperlink-40); --colour-hyperlink: var(--colour-hyperlink-40);
--colour-grey-fg: var(--colour-grey-40);
--colour-grey-bg: var(--colour-grey-80);
--colour-error-fg: var(--colour-error-20);
--colour-error-bg: var(--colour-error-80);
} }
} }
@@ -69,8 +100,8 @@ body {
line-height: 1.5; line-height: 1.5;
/* Geometric Humanist stack from https://modernfontstacks.com */ /* Geometric Humanist stack from https://modernfontstacks.com */
font-family: Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, font-family:
sans-serif; Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif;
} }
small { small {
@@ -81,6 +112,10 @@ small {
margin-block-start: var(--spacing-block-sm); margin-block-start: var(--spacing-block-sm);
} }
hr {
height: 2px;
}
/** Base layout */ /** Base layout */
body { body {
@@ -109,6 +144,7 @@ img {
[media-end content-start] [media-end content-start]
minmax(var(--grid-max-content-width), auto) minmax(var(--grid-max-content-width), auto)
[content-end grid-end]; [content-end grid-end];
grid-auto-rows: max-content;
column-gap: var(--spacing-block-sm); column-gap: var(--spacing-block-sm);
max-width: var(--grid-total-width); max-width: var(--grid-total-width);
@@ -127,7 +163,7 @@ img {
grid-column: grid; grid-column: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
> :is(section, header, aside) { > :is(section, header, aside, form) {
display: grid; display: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
grid-column: grid; grid-column: grid;
@@ -380,6 +416,67 @@ block-comment {
} }
} }
/* forms */
form {
margin-inline: auto;
max-width: max-content;
:is(button, fieldset, input, label, object, output, select, textarea, img) {
display: block;
}
label {
margin-block-end: var(--spacing-block-xs);
}
* + :is(label, input[type="submit"], button) {
margin-block-start: var(--spacing-block-md);
}
:is(input[type="submit"], input[type="button"], button) {
padding-inline: var(--spacing-inline-sm);
max-width: max-content;
&:not(dialog *) {
margin-inline-start: auto;
}
}
:is(input[type="email"], input[type="text"], select) {
max-width: 100%;
width: 16rem;
}
textarea {
max-width: 100%;
width: 40rem;
}
}
/* dialogs */
dialog {
background: var(--colour-primary-bg-accent);
border: 2px solid var(--colour-primary-fg-accent);
bottom: auto;
color: var(--colour-primary-fg);
left: calc(0.5 * (100vw - min(90vw, 36rem)));
margin: 0;
padding-block: var(--spacing-block-sm);
padding-inline: var(--spacing-inline-sm);
text-align: center;
top: auto;
width: min(90vw, 36rem);
}
@media (min-width: 80rem) {
dialog {
left: calc(
var(--body-margin-inline-start) + var(--grid-margin-inline) + 2 *
var(--spacing-inline-md)
);
}
}
/* utilities */ /* utilities */
:is( :is(
.para-spacing-tight:is(p, h1, h2, h3, h4, h5, h6, hr, img, figure, ul, ol), .para-spacing-tight:is(p, h1, h2, h3, h4, h5, h6, hr, img, figure, ul, ol),
@@ -387,3 +484,11 @@ block-comment {
) { ) {
margin-block-start: var(--spacing-block-xs); margin-block-start: var(--spacing-block-xs);
} }
.error {
background: var(--colour-error-bg);
border: 2px solid var(--colour-error-fg);
color: var(--colour-error-fg);
padding-block: var(--spacing--block-sm);
padding-inline: var(--spacing-inline-sm);
}

View File

@@ -27,10 +27,9 @@
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) {
.h-card { .h-card {
grid-column: media-start / content-end; grid-column: media-start / content-end;
@@ -40,6 +39,7 @@
grid-template-areas: grid-template-areas:
"empty heading" "empty heading"
"photo text"; "photo text";
column-gap: var(--spacing-block-sm);
} }
.h-card div:has(img) { .h-card div:has(img) {

View File

@@ -0,0 +1,29 @@
.otp-inputs {
display: flex;
flex-direction: row;
gap: var(--spacing-inline-sm);
justify-content: center;
input {
border: none;
border-block-end: 2px solid var(--colour-grey-fg);
font-size: var(--font-size-lg);
text-align: center;
width: var(--font-size-lg);
}
}
.otp-form {
align-items: center;
display: flex;
flex-direction: column;
gap: var(--spacing-block-sm);
> * {
margin: 0;
}
input[type="submit"] {
max-width: max-content;
}
}

View File

@@ -15,14 +15,22 @@ html {
} }
/* Remove default margin in favour of better control in authored CSS */ /* Remove default margin in favour of better control in authored CSS */
body, h1, h2, h3, h4, p, body,
figure, blockquote, dl, dd { h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin-block: 0; margin-block: 0;
} }
/* 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;
} }
@@ -32,14 +40,21 @@ body {
} }
/* Set shorter line heights on headings and interactive elements */ /* Set shorter line heights on headings and interactive elements */
h1, h2, h3, h4, h1,
button, input, label { h2,
h3,
h4,
button,
input,
label {
line-height: 1.1; line-height: 1.1;
} }
/* Balance text wrapping on headings */ /* Balance text wrapping on headings */
h1, h2, h1,
h3, h4 { h2,
h3,
h4 {
text-wrap: balance; text-wrap: balance;
} }
@@ -51,8 +66,10 @@ picture {
} }
/* Inherit fonts for inputs and buttons */ /* Inherit fonts for inputs and buttons */
input, button, input,
textarea, select { button,
textarea,
select {
font: inherit; font: inherit;
} }
@@ -71,3 +88,6 @@ textarea:not([rows]) {
padding: 0; padding: 0;
} }
[hidden] {
display: none;
}

View File

@@ -0,0 +1,7 @@
import otp from "./otp/otp";
import sendmail from "./sendmail";
export const server = {
otp,
sendmail,
};

View File

@@ -0,0 +1,7 @@
import send from "./send-otp";
import verify from "./verify-otp";
export default {
send,
verify,
};

View File

@@ -0,0 +1,45 @@
import crypto from "node:crypto";
import { z } from "astro/zod";
import { defineAction } from "astro:actions";
import { db, Otp } from "astro:db";
import { transporter } from "../sendmail";
export default defineAction({
input: z.object({
email: z.string().email(),
name: z.string().optional(),
type: z.enum(["email"]),
}),
handler: sendOtp,
});
type OtpParams = {
email: string;
name?: string;
type: "email";
};
async function sendOtp({ email, name }: OtpParams) {
const otp = crypto.randomBytes(3).toString("hex").toLocaleUpperCase();
const otpPretty = `${otp.slice(0, 3)}-${otp.slice(3)}`;
const info = await transporter.sendMail({
from: `"Joe Carstairs" <me@joeac.net>`,
to: `${name ? `"${name}" ` : ""}<${email}>`,
subject: `joeac.net: your OTP is ${otpPretty}`,
text: `
Someone tried to use this email address on joeac.net. If this was you,
your one-time passcode is ${otpPretty}. If this wasn't you, you don't need
to do anything.`,
});
console.log(
`Sent OTP (${otpPretty}) to ${email}. Message ID: ${info.messageId}`,
);
await db.insert(Otp).values({
userId: email,
value: otp,
createdAt: Date.now(),
validUntil: Date.now() + 1000 * 60 * 5,
});
}

View File

@@ -0,0 +1,48 @@
import { randomBytes } from "node:crypto";
import { z } from "astro/zod";
import { defineAction } from "astro:actions";
import { and, db, eq, gte, Otp, SendmailToken } from "astro:db";
export default defineAction({
input: z.object({
guess: z.string().length(6),
lenient: z.boolean().default(false),
userId: z.string().nonempty(),
}),
handler: verifyOtp,
});
async function verifyOtp({ guess, lenient, userId }: VerifyOtpParams) {
const leniency = lenient ? 1000 * 60 : 0;
const isOtpCorrect =
(await db.$count(
Otp,
and(
eq(Otp.userId, userId),
eq(Otp.value, guess),
gte(Otp.validUntil, Date.now() - leniency),
),
)) > 0;
if (!isOtpCorrect) {
return false;
}
await db.delete(Otp).where(and(eq(Otp.userId, userId), eq(Otp.value, guess)));
const token = randomBytes(256).toString("hex");
await db.insert(SendmailToken).values({
userId,
value: token,
createdAt: Date.now(),
validUntil: Date.now() + 60_000,
});
return token;
}
type VerifyOtpParams = {
guess: string;
lenient: boolean;
userId: string;
};

View File

@@ -0,0 +1,93 @@
import { z } from "astro/zod";
import { defineAction } from "astro:actions";
import nodemailer from "nodemailer";
import {
MAX_DAILY_EMAILS,
LOCAL_SMTP_HOST,
LOCAL_SMTP_PASSWORD,
LOCAL_SMTP_PORT,
LOCAL_SMTP_USER,
CONTACT_MAILBOX,
LOCAL_SMTP_ENVELOPE_FROM,
} from "astro:env/server";
import { and, db, eq, gte, SendmailToken, SentEmails } from "astro:db";
export default defineAction({
input: z.object({
email: z.string().email(),
message: z.string().nonempty(),
name: z.string().nonempty(),
userId: z.string().nonempty(),
token: z.string().nonempty(),
}),
handler: sendmail,
});
type SendEmailParams = {
email: string;
message: string;
name: string;
token: string;
userId: string;
};
async function sendmail({
name,
email,
message,
token,
userId,
}: SendEmailParams) {
const isTokenCorrect =
(await db.$count(
SendmailToken,
and(
eq(SendmailToken.userId, userId),
eq(SendmailToken.value, token),
gte(SendmailToken.validUntil, Date.now()),
),
)) > 0;
if (!isTokenCorrect) {
return false;
}
await db
.delete(SendmailToken)
.where(
and(eq(SendmailToken.userId, userId), eq(SendmailToken.value, token)),
);
const emailsSentLast24Hours = await db.$count(
SentEmails,
gte(SentEmails.sentAt, Date.now() - 1000 * 60 * 60 * 24),
);
if (emailsSentLast24Hours > MAX_DAILY_EMAILS) {
throw new Error(
`${emailsSentLast24Hours} emails have been sent in the last 24 hours, but the max daily load is ${MAX_DAILY_EMAILS}.`,
);
}
const info = await transporter.sendMail({
from: LOCAL_SMTP_ENVELOPE_FROM,
to: CONTACT_MAILBOX,
subject: `joeac.net: ${name} left a message`,
text: `${name} <${email}> sent you a message:\n\n\n${message}`,
});
await db
.insert(SentEmails)
.values({ messageId: info.messageId, sentAt: Date.now() });
console.log("Sent an email to Joe. Message ID: ", info.messageId);
}
export const transporter = nodemailer.createTransport({
host: LOCAL_SMTP_HOST,
from: LOCAL_SMTP_ENVELOPE_FROM,
port: LOCAL_SMTP_PORT,
secure: false,
authMethod: "PLAIN",
auth: {
type: "login",
user: LOCAL_SMTP_USER,
pass: LOCAL_SMTP_PASSWORD,
},
});

View File

@@ -23,9 +23,9 @@
Hi! 👋 My name is <span class="p-given-name">Joe</span> Hi! 👋 My name is <span class="p-given-name">Joe</span>
<span class="p-family-name">Carstairs</span>. Im a <span class="p-family-name">Carstairs</span>. Im a
<span class="p-job-title">software developer</span> at <span class="p-job-title">software developer</span> at
<a class="p-org" href="https://www.scottlogic.com">Scott Logic</a>, a <a class="p-org" href="https://www.scottlogic.com">Scott Logic</a>,
graduate of Philosophy and Mathematics at the University of Edinburgh, a Divinity student at the University of Edinburgh,
a committed Christian and a pretty rubbish poet. a lapsed fiddle player and a very occasional poet.
</p> </p>
<p> <p>

View File

@@ -12,5 +12,8 @@
<li> <li>
<a href="/links">Links</a> <a href="/links">Links</a>
</li> </li>
<li>
<a href="/contact">Contact</a>
</li>
</ul> </ul>
</nav> </nav>

View File

@@ -0,0 +1,31 @@
---
---
<script src="../scripts/otp-form-wc.ts"></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>

View File

@@ -0,0 +1,96 @@
---
import OtpDialog from "../components/OtpDialog.astro";
import Page from "../layouts/Page.astro";
---
<Page title="Contact" description="Contact Joe Carstairs">
<OtpDialog />
<form class="contact-form">
<h1>Contact me</h1>
<p hidden class="error"/>
<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>
import { resendOtp } from '../scripts/contact/resend-otp';
import { submitContactForm } from '../scripts/contact/submit-contact-form';
import { submitOtpForm } from '../scripts/contact/submit-otp-form';
import type { Selectors } from '../scripts/contact/selectors';
function locateOrPanic<T extends Element>(selector: string, desc: string, root?: Element): T {
const elem = (root ?? document).querySelector<T>(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<HTMLFormElement>('form.contact-form', 'contact form');
const otpDialog = locateOrPanic<HTMLDialogElement>('dialog.otp-dialog', 'OTP dialog');
const otpForm = locateOrPanic<HTMLFormElement>('form.otp-form', 'OTP form');
const successSection = document.querySelector('section.success');
let resendButtonInterval: undefined | NodeJS.Timeout = undefined;
const selectors: Selectors = {
contactForm: {
emailElem: () => locateOrPanic<HTMLInputElement>('input[name="email"]', 'email input', contactForm),
errorElem: () => contactForm.querySelector('.error'),
nameElem: () => locateOrPanic<HTMLInputElement>('input[name="name"]', 'name input', contactForm),
messageElem: () => locateOrPanic<HTMLTextAreaElement>('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<HTMLButtonElement>('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>
</Page>

View File

@@ -1,36 +0,0 @@
---
import Page from '../layouts/Page.astro';
---
<Page title="Joe's housewarming" description="Details for Joe's housewarming, July 2024">
<section>
<h1>Joe's housewarming</h1>
<p>
I, Joe Carstairs, hereby promise to keep this webpage updated with
accurate information.
</p>
<dl>
<dt>when</dt>
<dd>2pm-6pm Sunday 21 July 2024</dd>
<dt>where</dt>
<dd>57 Manor Place, EH3 7EG</dd>
<dt>what to wear</dt>
<dd>whatever you like (as long as it's decent)</dd>
<dt>what to bring</dt>
<dd>
good food<br>
&nbsp;&nbsp; and/or good drink<br>
&nbsp;&nbsp; and/or good chat<br>
&nbsp;&nbsp; and/or good tunes<br>
&nbsp;&nbsp; and/or a good game<br>
&nbsp;&nbsp; and/or just your good self
</dd>
</dl>
</section>
</Page>

View File

@@ -0,0 +1,30 @@
import type { Selectors } from "./selectors";
export function postErrorMessageOnContactForm(
{ contactForm }: Selectors,
errorMsg: string,
) {
const errorElem = contactForm.errorElem();
if (errorElem) {
errorElem.textContent = errorMsg;
errorElem.removeAttribute("hidden");
} else {
alert(errorMsg);
}
}
export function postErrorMessageOnOtpForm(
{ otpDialog }: Selectors,
errorMsg: string,
) {
const errorElem = otpDialog.errorElem();
if (errorElem) {
errorElem.textContent = errorMsg;
errorElem.removeAttribute("hidden");
} else {
alert(errorMsg);
}
for (const input of otpDialog.allOtpInputs()) {
(input as HTMLInputElement).value = "";
}
}

View File

@@ -0,0 +1,29 @@
import { actions } from "astro:actions";
import { resetResendButton } from "./reset-resend-button";
import { postErrorMessageOnOtpForm } from "./post-error-message";
import type { Selectors } from "./selectors";
const fallbackErrorMsg =
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
export async function resendOtp(
selectors: Selectors,
resetButtonInterval: NodeJS.Timeout | undefined,
): Promise<Result> {
const result = resetResendButton(selectors, resetButtonInterval);
const name = selectors.contactForm.nameElem()?.value;
const email = selectors.contactForm.emailElem().value;
const sendOtpResult = await actions.otp.send({ type: "email", name, email });
if (sendOtpResult.error) {
const errorMsg = sendOtpResult.error?.toString() ?? fallbackErrorMsg;
postErrorMessageOnOtpForm(selectors, errorMsg);
throw sendOtpResult.error;
}
return result;
}
type Result = {
resendButtonInterval: NodeJS.Timeout | undefined;
};

View File

@@ -0,0 +1,32 @@
import type { Selectors } from "./selectors";
export function resetResendButton(
{ otpDialog }: Selectors,
resendButtonInterval: NodeJS.Timeout | undefined,
): Result {
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 };
}
type Result = {
resendButtonInterval: NodeJS.Timeout | undefined;
};

View File

@@ -0,0 +1,27 @@
export type Selectors = {
contactForm: {
emailElem: () => HTMLInputElement;
errorElem: () => Element | null;
nameElem: () => HTMLInputElement;
messageElem: () => HTMLTextAreaElement;
self: () => HTMLFormElement | null;
submitButton: () => HTMLInputElement | null;
};
otpDialog: {
allOtpInputs: () => NodeListOf<HTMLInputElement>;
errorElem: () => Element | null;
firstOtpInput: () => HTMLInputElement | null;
otpForm: () => HTMLFormElement | null;
otpRecipient: () => Element | null;
otpValidUntil: () => Element | null;
resendButton: () => HTMLButtonElement | null;
self: () => HTMLDialogElement;
submitButton: () => HTMLInputElement | null;
};
successSection: {
email: () => Element | null;
message: () => Element | null;
name: () => Element | null;
self: () => Element | null;
};
};

View File

@@ -0,0 +1,53 @@
import { actions } from "astro:actions";
import { postErrorMessageOnContactForm } from "./post-error-message";
import { resetResendButton } from "./reset-resend-button";
import type { Selectors } from "./selectors";
const fallbackErrorMsg =
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
export async function submitContactForm(
selectors: Selectors,
resendButtonInterval: NodeJS.Timeout | undefined,
): Promise<Result> {
const { contactForm, otpDialog } = selectors;
const name = contactForm.nameElem()?.value;
const email = contactForm.emailElem().value;
contactForm.submitButton()?.setAttribute("disabled", "");
const sendOtpResult = await actions.otp.send({ type: "email", name, email });
if (sendOtpResult.error) {
const errorMsg = sendOtpResult.error?.toString() ?? fallbackErrorMsg;
postErrorMessageOnContactForm(selectors, errorMsg);
throw sendOtpResult.error;
}
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);
}
type Result = {
resendButtonInterval: NodeJS.Timeout | undefined;
};

View File

@@ -0,0 +1,72 @@
import { actions } from "astro:actions";
import type { Selectors } from "./selectors";
import {
postErrorMessageOnContactForm,
postErrorMessageOnOtpForm,
} from "./post-error-message";
const fallbackErrorMsg =
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
export async function submitOtpForm(selectors: 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;
const verifyResult = await actions.otp.verify({ guess, userId: email });
if (verifyResult.error) {
otpDialog.submitButton()?.removeAttribute("disabled");
postErrorMessageOnOtpForm(
selectors,
verifyResult.error?.toString() ?? fallbackErrorMsg,
);
return;
}
if (!verifyResult.data) {
otpDialog.submitButton()?.removeAttribute("disabled");
postErrorMessageOnOtpForm(selectors, "Incorrect OTP. Check your email?");
otpDialog.firstOtpInput()?.focus();
return;
}
const sendmailToken = verifyResult.data;
const sendmailResult = await actions.sendmail({
email,
message: message,
name: name,
userId: email,
token: sendmailToken,
});
if (sendmailResult.error) {
const errorMsg = sendmailResult.error?.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();
}

View File

@@ -0,0 +1,68 @@
class OtpForm extends HTMLElement {
observer?: MutationObserver;
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");
const inputs = this.querySelectorAll(
'input:not([type="submit"])',
) as NodeListOf<HTMLInputElement>;
for (const input of inputs) {
input.value = "";
}
}
configureInputs() {
this.observer?.disconnect();
const inputs = this.querySelectorAll(
'input:not([type="submit"])',
) as NodeListOf<HTMLInputElement>;
const form = this.querySelector("form") as HTMLFormElement;
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();
const nextInput = input.nextElementSibling as HTMLInputElement;
if (nextInput) {
nextInput.focus();
return;
}
const submitButton = form.querySelector(
'input[type="submit"]',
) as HTMLInputElement;
submitButton.focus();
let areAllInputsEntered = true;
inputs.forEach((input) => {
areAllInputsEntered = areAllInputsEntered && input.value.length > 0;
});
if (areAllInputsEntered) {
form.requestSubmit(submitButton);
}
}
});
}
}
}
customElements.define("otp-form", OtpForm);