Compare commits

...

35 Commits

Author SHA1 Message Date
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
41 changed files with 3429 additions and 864 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

@@ -24,22 +24,3 @@ To run with Node:
```bash ```bash
npm run start npm run start
``` ```
## Infrastructure
The infrastructure is on DigitalOcean.
The website is hosted using the App Platform service from DigitalOcean. This is
free for static websites, and is quite flexible to add in extra apps as Droplets
or Functions at a later time if I so please.
DigitalOcean App Platform re-deploys the website every time there's an update to
the `main` branch in this repo.
All the DigitalOcean infrastructure is managed using Terraform. The code for
this is in the `infrastructure/` directory.
The domain, however, is registered on AWS. The nameservers registered in AWS
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
once.

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

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 } 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,26 @@ 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" }),
},
},
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 build && node ./dist/server/entry.mjs", "start": "astro build --remote && node ./dist/server/entry.mjs",
"build": "cd website && astro build", "build": "astro build --remote",
"preview": "cd website && 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

@@ -1,57 +1,57 @@
.h-card div:has(img) { .h-card div:has(img) {
width: 6rem; width: 6rem;
height: 6rem; height: 6rem;
margin-inline: auto; margin-inline: auto;
} }
.h-card img { .h-card img {
width: 6rem; width: 6rem;
height: 6rem; height: 6rem;
border-radius: 1rem; border-radius: 1rem;
filter: contrast(1.25); filter: contrast(1.25);
} }
.h-card div:has(img)::after { .h-card div:has(img)::after {
/* Colour overlay */ /* Colour overlay */
background-color: var(--colour-primary-80); background-color: var(--colour-primary-80);
opacity: 0.3; opacity: 0.3;
/* Same size and shape as the img */ /* Same size and shape as the img */
border-radius: 1rem; border-radius: 1rem;
width: 6rem; width: 6rem;
height: 6rem; height: 6rem;
/* Positioned on top of the img */ /* Positioned on top of the img */
display: block; display: block;
position: relative; position: relative;
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;
display: grid; display: grid;
grid-template-columns: subgrid; /** Subgrid of main column layout */ grid-template-columns: subgrid; /** Subgrid of main column layout */
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
grid-template-areas: grid-template-areas:
"empty heading" "empty heading"
"photo text"; "photo text";
} column-gap: var(--spacing-block-sm);
}
.h-card div:has(img) { .h-card div:has(img) {
grid-area: photo; grid-area: photo;
margin-block-start: var(--spacing-block-sm); margin-block-start: var(--spacing-block-sm);
} }
.h-card header { .h-card header {
grid-area: heading; grid-area: heading;
} }
.h-card__text { .h-card__text {
grid-area: text; grid-area: text;
} }
} }

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

@@ -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);