Compare commits

...

4 commits

23 changed files with 268 additions and 526 deletions

View file

@ -9,4 +9,4 @@ docker-compose*
README.md README.md
LICENSE LICENSE
.vscode .vscode
.env.* .env

View file

@ -1,72 +0,0 @@
name: Build And Deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Install dependencies
run: |
apt-get update && apt-get install gettext dnsutils iputils-ping -y
- name: Check out repository
uses: actions/checkout@v4
- name: Substitute environment variables in .env.example and write to .env
env:
CERTBOT_EMAIL: ${{secrets.CERTBOT_EMAIL}}
CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}}
ANDROID_SMS_GATEWAY_LOGIN: ${{secrets.ANDROID_SMS_GATEWAY_LOGIN}}
ANDROID_SMS_GATEWAY_PASSWORD: ${{secrets.ANDROID_SMS_GATEWAY_PASSWORD}}
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE: ${{secrets.ANDROID_SMS_GATEWAY_RECIPIENT_PHONE}}
OTP_SUPER_SECRET_SALT: ${{secrets.OTP_SUPER_SECRET_SALT}}
SSH_USER: ${{secrets.SSH_USER}}
SSH_KNOWN_HOST: ${{secrets.SSH_KNOWN_HOST}}
ASTRO_DB_REMOTE_URL: ${{secrets.ASTRO_DB_REMOTE_URL}}
SSH_KEY: ${{secrets.SSH_KEY}}
WIREGUARD_PRIVATE_KEY: ${{secrets.WIREGUARD_PRIVATE_KEY}}
WIREGUARD_PUBLIC_KEY: ${{secrets.WIREGUARD_PUBLIC_KEY}}
DNS_SERVER: ${{vars.DNS_SERVER}}
DNS_ADDRESS: ${{vars.DNS_ADDRESS}}
DOMAIN: ${{vars.DOMAIN}}
PUBLIC_IP: ${{vars.PUBLIC_IP}}
ANDROID_SMS_GATEWAY_IP: ${{vars.ANDROID_SMS_GATEWAY_IP}}
ANDROID_SMS_GATEWAY_URL: ${{vars.ANDROID_SMS_GATEWAY_URL}}
IMAGE_FILENAME: ${{vars.IMAGE_FILENAME}}
IMAGE_NAME: ${{vars.IMAGE_NAME}}
SSH_PORT: ${{vars.SSH_PORT}}
SSH_HOST: ${{vars.SSH_HOST}}
WIREGUARD_ALLOWED_IPS: ${{vars.WIREGUARD_ALLOWED_IPS}}
WIREGUARD_ADDRESSES: ${{vars.WIREGUARD_ADDRESSES}}
WIREGUARD_ENDPOINT_HOST: ${{vars.WIREGUARD_ENDPOINT_HOST}}
WIREGUARD_ENDPOINT_PORT: ${{vars.WIREGUARD_ENDPOINT_PORT}}
HEALTH_TARGET_ADDRESSES: ${{vars.HEALTH_TARGET_ADDRESSES}}
HEALTH_ICMP_TARGET_IPS: ${{vars.HEALTH_ICMP_TARGET_IPS}}
VERSION_INFORMATION: ${{vars.VERSION_INFORMATION}}
PUBLICIP_ENABLED: ${{vars.PUBLICIP_ENABLED}}
run: |
envsubst < .env.example > .env
# - name: Export secrets and variables to $GITHUB_ENV
# env:
# SECRETS_CONTEXT: ${{ toJSON(secrets) }}
# VARS_CONTEXT: ${{ toJSON(vars) }}
# run: |
# EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
# to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; }
# echo "$VARS_CONTEXT" | to_envs >> $GITHUB_ENV
# echo "$SECRETS_CONTEXT" | to_envs >> $GITHUB_ENV
# - name: Update .env with secrets and variables
# run: |
# envsubst < .env.example > .env
# cat .env
- name: Run build script
run: |
cd cicd/scripts
chmod +x ./build.sh
./build.sh
- name: Run deploy script
run: |
cd cicd/scripts
chmod +x ./deploy.sh
./deploy.sh

View file

@ -1,4 +1,4 @@
FROM oven/bun:1 AS base FROM oven/bun:1@sha256:50317d83cd5a5ae1d8b35b3379c69f57ce1a0dbf4def91f0965653d767851834 AS base
WORKDIR /usr/src/app WORKDIR /usr/src/app
# install dependencies into temp directory # install dependencies into temp directory
@ -27,7 +27,7 @@ RUN bun run build-remote
# copy production dependencies and source code into final image # copy production dependencies and source code into final image
# and run with node to avoid some random issues with bun # and run with node to avoid some random issues with bun
# (e.g. bun's fetch doesn't support dispatchers) # (e.g. bun's fetch doesn't support dispatchers)
FROM node:20-slim AS release FROM node:24-slim@sha256:44a253901c13323953b4a0e1df5afe08bf8ac4ac086edd500422b54cb6e6eeb0 AS release
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY --from=install /temp/prod/node_modules node_modules COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/dist ./dist COPY --from=prerelease /usr/src/app/dist ./dist

View file

@ -1,6 +1,6 @@
import htmx from "astro-htmx"; import htmx from "astro-htmx";
// @ts-check // @ts-check
import { defineConfig, envField } from "astro/config"; import { defineConfig, sessionDrivers, envField } from "astro/config";
import alpinejs from "@astrojs/alpinejs"; import alpinejs from "@astrojs/alpinejs";
import sitemap from "@astrojs/sitemap"; import sitemap from "@astrojs/sitemap";
import bun from "@nurodev/astro-bun"; import bun from "@nurodev/astro-bun";
@ -23,13 +23,11 @@ export default defineConfig({
prefetch: { prefetch: {
prefetchAll: true, prefetchAll: true,
}, },
security: {
checkOrigin: false,
},
session: { session: {
driver: "lru-cache", driver: sessionDrivers.lruCache({
ttl: 3600, ttl: 3600,
maxEntries: 1000, maxEntries: 1000,
}),
}, },
server: { server: {
host: true, host: true,
@ -61,8 +59,6 @@ export default defineConfig({
}, },
integrations: [alpinejs(), sitemap(), htmx(), db()], integrations: [alpinejs(), sitemap(), htmx(), db()],
experimental: { experimental: {
preserveScriptOrder: true,
chromeDevtoolsWorkspace: true, chromeDevtoolsWorkspace: true,
failOnPrerenderConflict: true,
}, },
}); });

BIN
bun.lockb

Binary file not shown.

View file

@ -1,30 +0,0 @@
#!/bin/bash
set -eu
#######################
# VARIABLES #
#######################
ROOT_DIR=$(dirname $(dirname $(dirname $(realpath $0))))
GIT_REF=${GIT_REF:-main}
### NO EDITS BELOW THIS LINE ###
cd ${ROOT_DIR}
source .env
git checkout ${GIT_REF}
GIT_SHA=$(git rev-parse --short HEAD)
if [[ "${GIT_REF}" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$ ]]; then
VERSION="${BASH_REMATCH[1]}"
if [[ -n "${BASH_REMATCH[2]}" ]]; then
VERSION="${VERSION}${BASH_REMATCH[2]}"
fi
echo "Using git tag version: ${VERSION}"
else
VERSION=$(node -p "require('./package.json').version || '0.0.0'")
GIT_SHA_SHORT="${GIT_SHA:0:7}"
VERSION="${VERSION}-${GIT_SHA_SHORT}"
echo "Using package.json + SHA version: ${VERSION}"
fi
docker build -t ${IMAGE_NAME}:latest -t ${IMAGE_NAME}:v${VERSION} --build-arg VERSION=${VERSION} .
docker save -o ${IMAGE_FILENAME} ${IMAGE_NAME}:latest

View file

@ -1,47 +0,0 @@
#!/bin/bash
set -eu
#######################
# VARIABLES #
#######################
ROOT_DIR=$(dirname $(dirname $(dirname $(realpath $0))))
### NO EDITS BELOW THIS LINE ###
cd ${ROOT_DIR}
source .env
mkdir -p ${HOME}/.ssh
chmod 700 ${HOME}/.ssh
echo "${SSH_KEY}" > ${HOME}/.ssh/id_ed25519-${SSH_HOST//./_}
echo "${SSH_KNOWN_HOST}" > ${HOME}/.ssh/known_hosts-${SSH_HOST//./_}
chmod -R 600 ${HOME}/.ssh/
chmod 700 ${HOME}/.ssh
grep -q "Host ${SSH_HOST}" ${HOME}/.ssh/config 2>&1 1>/dev/null || cat >> ${HOME}/.ssh/config <<EOF
Host ${SSH_HOST}
HostName ${SSH_HOST}
User ${SSH_USER}
Port ${SSH_PORT}
IdentityFile ${HOME}/.ssh/id_ed25519-${SSH_HOST//./_}
UserKnownHostsFile ${HOME}/.ssh/known_hosts-${SSH_HOST//./_}
StrictHostKeyChecking yes
ControlMaster auto
ControlPath ~/.ssh/control-%C
ControlPersist yes
ConnectionAttempts 3
ConnectTimeout 10
ServerAliveInterval 10
EOF
WIREGUARD_ENDPOINT_IP=$(dig +short $WIREGUARD_ENDPOINT_HOST | tail -n1)
[[ -z ${WIREGUARD_ENDPOINT_IP} ]] && echo "Failed to resolve IP address for WIREGUARD_ENDPOINT_HOST" >&2 && exit 1
echo "WIREGUARD_ENDPOINT_IP=${WIREGUARD_ENDPOINT_IP}" >> .env
DOCKER_HOST=ssh://${SSH_HOST} docker load -i ${IMAGE_FILENAME}
ssh ${SSH_HOST} "mkdir -p /srv/${IMAGE_NAME#*/}/"
ssh ${SSH_HOST} "cd /srv/${IMAGE_NAME#*/}/ && docker compose down"
scp .env ${SSH_HOST}:/srv/${IMAGE_NAME#*/}/.env
cd deploy
scp -r . ${SSH_HOST}:/srv/${IMAGE_NAME#*/}/
ssh ${SSH_HOST} "cd /srv/${IMAGE_NAME#*/}/ && docker compose up -d"

View file

@ -1,4 +0,0 @@
#!/bin/bash
cat /etc/letsencrypt/live/badblocks.dev/fullchain.pem /etc/letsencrypt/live/badblocks.dev/privkey.pem > /etc/letsencrypt/fullcert.pem
chmod 755 /etc/letsencrypt/
chmod 644 /etc/letsencrypt/fullcert.pem

View file

@ -5,25 +5,22 @@ services:
container_name: badblocks-personal-site container_name: badblocks-personal-site
volumes: volumes:
- ./db:/db - ./db:/db
networks:
- proxynet
env_file: env_file:
- .env - .env
# healthcheck: healthcheck:
# test: test:
# [ [
# "CMD", "node",
# "curl", "-e",
# "-f", "fetch('http://localhost:4321/health').then(r=>process.exit(r.ok?0:1))",
# "-s", ]
# "--max-time", interval: 30s
# "5", timeout: 15s
# "http://localhost:4321/health", retries: 3
# ] start_period: 120s
# interval: 30s cap_drop: [ALL]
# timeout: 15s security_opt: [no-new-privileges:true]
# retries: 3 read_only: true
# start_period: 120s
wireguard: wireguard:
image: qmcgaw/gluetun image: qmcgaw/gluetun
cap_add: cap_add:
@ -41,46 +38,13 @@ services:
devices: devices:
- /dev/net/tun:/dev/net/tun - /dev/net/tun:/dev/net/tun
restart: unless-stopped restart: unless-stopped
networks:
- proxynet
# healthcheck: # healthcheck:
# test: ["CMD", "ping", "-c", "1", "-W", "3", "$$ANDROID_SMS_GATEWAY_IP"] # test: ["CMD", "ping", "-c", "1", "-W", "3", "$$ANDROID_SMS_GATEWAY_IP"]
# interval: 30s # interval: 30s
# timeout: 15s # timeout: 15s
# retries: 3 # retries: 3
# start_period: 60s # start_period: 60s
certbot:
image: serversideup/certbot-dns-cloudflare
container_name: certbot
volumes:
- ./certs:/etc/letsencrypt
environment:
CLOUDFLARE_API_TOKEN: "${CLOUDFLARE_API_TOKEN}"
CERTBOT_EMAIL: "${CERTBOT_EMAIL}"
CERTBOT_DOMAINS: "${DOMAIN}"
haproxy:
image: haproxy:3.2
stop_signal: SIGTERM
container_name: haproxy
env_file:
- .env
command: ["haproxy", "-f", "/usr/local/etc/haproxy"]
ports:
- "${PUBLIC_IP}:80:80"
- "${PUBLIC_IP}:443:443"
- "${PUBLIC_IP}:8404:8404"
volumes:
- ./haproxy:/usr/local/etc/haproxy:ro
- ./certs:/certs:ro
restart: always
networks:
- proxynet
# healthcheck:
# test: ["CMD", "haproxy", "-c", "-f", "/usr/local/etc/haproxy"]
# interval: 30s
# timeout: 10s
# retries: 3
networks: networks:
proxynet: default:
name: proxynet name: proxynet
driver: bridge external: true

View file

@ -1,143 +0,0 @@
<!doctype html>
<meta name="viewport" content="width=device-width" />
<title>Challenge Accepted!</title>
<style>
body {
background: #ddd;
color: #000;
margin: 0;
padding: 0;
}
#progress {
position: absolute;
margin: 0;
top: 50%%;
left: 50%%;
transform: translate(-50%%, -50%%);
text-align: center;
font-size: 125%%;
}
#progressBar {
font-size: 250%%;
user-select: none;
}
#progressBar.done {
transition:
font-size 2s linear,
opacity 2s linear;
font-size: 1000%%;
opacity: 0.5;
}
.animate {
animation: spin 2s infinite linear;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
iframe {
display: none;
}
footer {
position: fixed;
bottom: 4px;
width: 100%%;
text-align: center;
}
</style>
<form method="post" target="post" action="/_challenge" name="challenge">
<input type="hidden" name="ip" value="%[src]" />
<input type="hidden" name="ts" value="%[date]" />
<input type="hidden" name="diff" value="4" />
<input type="hidden" name="tries" value="" />
</form>
<div id="progress">
<span id="progressText"></span>
<div id="progressBar"></div>
</div>
<iframe srcdoc="" src="about:blank" name="post"></iframe>
<script>
"use strict";
async function challenge(diff, ip, ts) {
let te = new TextEncoder();
let tries = 0;
progressText.innerText = "✋ Checking connection, please wait";
progressBar.innerText = "🌀";
progressBar.className = "animate";
for (; tries < 10_000_000; tries++) {
let hash = await crypto.subtle.digest(
"SHA-256",
te.encode([ip, location.hostname, ts, tries].join(";")),
);
let y = new Uint8Array(hash);
let i = 0;
while (i < diff / 2) {
if (y[i] > 0x0f) break;
if (i * 2 + 1 >= diff) return tries;
if (y[i++] > 0) break;
if (i * 2 >= diff) return tries;
}
}
}
async function startChallenge(form) {
if (!("subtle" in window.crypto)) {
if (location.protocol !== "https:") {
progress.innerText =
"No WebCrypto support. This must be served over a HTTPS connection.";
} else {
progress.innerText =
"No WebCrypto support in your browser. " +
"This is required to pass the challenge.";
}
return;
}
let backoff = 0;
let tryOnce = (_) => {
if (backoff > 8) {
progressText.innerText =
"Failed to submit after several tries. Try reloading.";
return;
}
setTimeout(
async (_) => submitAnswer(form),
1000 * (Math.pow(2, backoff++) - 1),
);
};
form.addEventListener("error", tryOnce);
let iframe = document.querySelector("iframe");
iframe.addEventListener("load", (_) =>
location.hash.length ? location.reload() : location.replace(location),
);
tryOnce();
}
async function submitAnswer(form) {
let start = new Date();
let tries = await challenge(form.diff.value, form.ip.value, form.ts.value);
if (tries === undefined) {
progressText.innerText =
"Unable to calculate challenge. Try reloading or a different browser.";
progressBar.innerText = "🤯";
progressBar.className = "error";
return;
}
form.tries.value = tries;
progressText.innerText = "Took " + (new Date() - start) + "ms";
console.log(`${tries} tries.\n${progressText.innerText}`);
progressBar.className = "done";
progressBar.innerText = "✅";
form.submit();
}
window.addEventListener("load", (_) =>
startChallenge(document.forms.challenge),
);
</script>
<noscript
>Malicious scrapers break the web. To continue, you'll need JavaScript
enabled.</noscript
>

View file

@ -1,85 +0,0 @@
global
daemon
log stdout format raw local0 info
maxconn 2000
# For normalize-uri
expose-experimental-directives
defaults
mode http
log global
timeout connect 5s
timeout client 30s
timeout server 30s
timeout check 5s
retries 3
option httplog
option dontlognull
option redispatch
frontend http
bind :80
mode http
http-request redirect scheme https unless { ssl_fc }
frontend www
bind :443 ssl crt /certs/fullcert.pem
# 2 general purpose tags in this stick-table (name defaults to frontend name, i.e. www)
stick-table type ipv6 size 1m expire 2d store gpt(2)
http-request track-sc0 src
http-request normalize-uri path-merge-slashes
http-request normalize-uri path-strip-dot
http-request normalize-uri path-strip-dotdot
# Drop the connection immediately if the requester previously requested the honeypot path)
http-request silent-drop if { sc_get_gpt(0,0) gt 0 }
# Protect all paths except /robots.txt, /.well-known/*, and /favicon.ico
acl unprotected_path path -m reg ^/(robots.txt|\.well-known/.*|favicon\.ico|_challenge)$
# Matches the default config of anubis of triggering on "Mozilla"
acl protected_ua hdr(User-Agent) -m beg Mozilla/
# Set stick table index 0 to 1 if request is for honeypot path
http-request sc-set-gpt(0,0) 1 if { path -m beg /blokmeplz/ }
http-request silent-drop if { path -m beg /blokmeplz/ }
acl accepted sc_get_gpt(1,0) gt 0
http-request return status 200 content-type "text/html; charset=UTF-8" hdr "Cache-control" "max-age=0, no-cache" lf-file /usr/local/etc/haproxy/challenge.html if !unprotected_path protected_ua !accepted
use_backend challenge if { path -m beg /_challenge }
http-response set-header Strict-Transport-Security "max-age=16000000; includeSubDomains; preload;"
default_backend main
backend challenge
mode http
option http-buffer-request
# The parameter to table must match the stick table used in the frontend.
http-request track-sc0 src table www
acl challenge_req method POST
http-request set-var(txn.tries) req.body_param(tries)
http-request set-var(txn.ts) req.body_param(ts)
http-request set-var(txn.host) hdr(Host),host_only
http-request set-var(txn.hash) src,concat(;,txn.host,),concat(;,txn.ts,),concat(;,txn.tries),sha2,hex
acl ts_recent date,neg,add(txn.ts) ge -60
# 4 is the difficulty, should match "diff" in challenge.html.
acl hash_good var(txn.hash) -m reg 0{4}.*
http-request sc-set-gpt(1,0) 1 if challenge_req ts_recent hash_good
http-request return status 200 if challenge_req hash_good
http-request return status 400 content-type "text/html; charset=UTF-8" hdr "Cache-control" "max-age=0" string "Bad request" if !challenge_req OR !hash_good
backend main
mode http
balance leastconn
option httpchk GET /health
http-check expect status 200
server badblocks-personal-site badblocks-personal-site:4321 check resolvers docker resolve-prefer ipv4 init-addr none
resolvers docker
nameserver dns1 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold valid 10s
hold obsolete 30s

View file

@ -2,7 +2,7 @@ services:
personal-site: personal-site:
build: . build: .
ports: ports:
- 4321:4321 - 127.0.0.1:4321:4321
restart: no restart: no
logging: logging:
options: options:

View file

@ -14,34 +14,34 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/alpinejs": "^0.4.9", "@astrojs/alpinejs": "^1.0.0",
"@astrojs/check": "^0.9.6", "@astrojs/check": "^0.9.9",
"@astrojs/db": "^0.18.3", "@astrojs/db": "^0.21.3",
"@astrojs/node": "^9.5.4", "@astrojs/node": "^11.0.2",
"@astrojs/partytown": "^2.1.4", "@astrojs/partytown": "^2.1.7",
"@astrojs/sitemap": "^3.7.0", "@astrojs/sitemap": "^3.7.3",
"@astrojs/ts-plugin": "^1.10.6", "@astrojs/ts-plugin": "^1.10.10",
"@cap.js/server": "^4.0.5", "@cap.js/server": "^4.0.5",
"@cap.js/widget": "^0.1.35", "@cap.js/widget": "^0.1.56",
"@nurodev/astro-bun": "^2.1.2", "@nurodev/astro-bun": "^2.1.2",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"alpinejs": "^3.15.8", "alpinejs": "^3.15.12",
"android-sms-gateway": "^3.0.0", "android-sms-gateway": "^3.0.0",
"astro": "^5.18.0", "astro": "^7.0.6",
"astro-htmx": "^1.0.6", "astro-htmx": "^1.0.6",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.10",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"ofetch": "^1.5.1", "ofetch": "^1.5.1",
"otplib": "^12.0.1", "otplib": "12.0.1",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"undici": "^7.22.0", "undici": "^8.6.0",
"validator": "^13.15.26" "validator": "^13.15.35"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.9", "@types/bun": "^1.3.14",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"doiuse": "^6.0.6", "doiuse": "^6.0.6",
"prettier": "^3.8.1", "prettier": "^3.9.4",
"prettier-plugin-astro": "^0.14.1" "prettier-plugin-astro": "^0.14.1"
} }
} }

View file

@ -0,0 +1,6 @@
version: STSv1
mode: enforce
mx: mx.zoho.com
mx: mx2.zoho.com
mx: mx3.zoho.com
max_age: 604800

6
public/crt.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<title>CRT Icon</title>
<desc>An icon of a CRT monitor, representing retro technology.</desc>
<rect width="512" height="512" fill="none"/>
<path fill="currentColor" d="m132.992 48.756l-9.984 14.976l77.287 51.524c4.32-4.192 9.542-8.012 15.703-11.162zm246.016 0l-83.006 55.338c6.16 3.15 11.383 6.97 15.703 11.162l77.287-51.524zM256 113.244c-22 0-35.334 7.06-43.637 15.363c-4.703 4.704-7.58 9.97-9.316 14.637h105.906c-1.736-4.668-4.613-9.933-9.316-14.637c-8.303-8.303-21.637-15.363-43.637-15.363m-191.936 48c-5.044 0-11.106 2.784-15.697 7.37c-4.59 4.584-7.367 10.63-7.367 15.63v279h430V184.2c.026-5.457-2.764-11.393-7.227-15.87s-10.346-7.086-14.826-7.086zm30.295 23h257.745c10.374 0 20.79 6.818 26.776 16H112c-16 0-32 16-32 32v160c25.924-162.54 145.877-185.697 299.016-191.795c3.106 4.836 4.984 10.315 4.984 15.794v192c0 16-16 32-33.88 32H95.35c-15.35 0-31.35-16-31.35-32v-192c0-16 16-32 30.36-32zm328.663 7c13.7 0 25 11.3 25 25s-11.3 25-25 25s-25-11.3-25-25s11.3-25 25-25m-23.023 144h48v18h-48zm0 32h48v18h-48zm0 32h48v18h-48z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

10
public/hi-res.svg Normal file
View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<title>Hi-Res Icon</title>
<desc>An icon of a modern monitor, indicating high resolution.</desc>
<rect width="24" height="24" fill="none"/>
<g fill="none">
<path fill="#444" d="M2.435 11.522V1.957A.957.957 0 0 1 3.39 1h17.22a.956.956 0 0 1 .957.957v9.565z"/>
<path fill="#777" d="M17.261 1H3.391a.957.957 0 0 0-.956.957v9.565h4.304z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M7.696 18.218h8.609m-13.87-6.696h19.13M20.609 1a.956.956 0 0 1 .957.957v11.478a.957.957 0 0 1-.957.957H3.391a.957.957 0 0 1-.956-.957V1.957A.957.957 0 0 1 3.39 1zM12 20.13V23m-7.652 0h15.304" stroke-width="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 743 B

View file

@ -3,13 +3,15 @@ import { z } from "astro/zod";
import type { ActionAPIContext } from "astro:actions"; import type { ActionAPIContext } from "astro:actions";
import validator from "validator"; import validator from "validator";
import SmsClient from "@lib/SmsGatewayClient.ts"; import SmsClient from "@lib/SmsGatewayClient.ts";
import Otp, { verifyOtp } from "@lib/Otp.ts"; import Otp, { verifyOtp, normalizePhone } from "@lib/Otp.ts";
import { createCap } from "@lib/CapAdapter"; import { createCap } from "@lib/CapAdapter";
import { import {
OTP_SUPER_SECRET_SALT, OTP_SUPER_SECRET_SALT,
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
} from "astro:env/server"; } from "astro:env/server";
const MAX_OTP_VERIFY_ATTEMPTS = 5;
const isValidMobilePhone: [(data: string) => any, { message: string }] = [ const isValidMobilePhone: [(data: string) => any, { message: string }] = [
(value: string) => (value: string) =>
validator.isMobilePhone(value, ["en-US", "en-CA"]) && validator.isMobilePhone(value, ["en-US", "en-CA"]) &&
@ -39,7 +41,11 @@ const captcha_input = z.string().trim().nonempty();
const sendOtpAction = z.object({ const sendOtpAction = z.object({
action: z.literal("send_otp"), action: z.literal("send_otp"),
name: z.string().trim().min(5).max(32).transform(stripDisallowedCharacters), name: z
.string()
.trim()
.transform(stripDisallowedCharacters)
.pipe(z.string().min(5).max(32)),
phone: z phone: z
.string() .string()
.trim() .trim()
@ -47,11 +53,16 @@ const sendOtpAction = z.object({
msg: z msg: z
.string() .string()
.trim() .trim()
.min(25)
.max(512)
.transform(stripDisallowedCharacters) .transform(stripDisallowedCharacters)
.refine(...noYelling) .pipe(
.refine(...noExcessiveRepetitions), z
.string()
.min(25)
.max(512)
.refine(...noYelling)
.refine(...noExcessiveRepetitions),
),
captcha: captcha_input, captcha: captcha_input,
}); });
@ -63,6 +74,7 @@ const sendMsgAction = z.object({
const resetAction = z.object({ const resetAction = z.object({
action: z.literal("reset"), action: z.literal("reset"),
captcha: captcha_input,
}); });
const formAction = z.discriminatedUnion("action", [ const formAction = z.discriminatedUnion("action", [
@ -73,7 +85,10 @@ const formAction = z.discriminatedUnion("action", [
const submitActionDefinition = { const submitActionDefinition = {
input: formAction, input: formAction,
handler: async (input: any, context: ActionAPIContext) => { handler: async (
input: z.infer<typeof formAction>,
context: ActionAPIContext,
) => {
if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) { if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) {
throw new ActionError({ throw new ActionError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
@ -83,12 +98,10 @@ const submitActionDefinition = {
const cap = createCap(context.session ?? null); const cap = createCap(context.session ?? null);
if ( if (!(
!( /^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(input.captcha) &&
/^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(input.captcha) && (await cap.validateToken(input.captcha))
(await cap.validateToken(input.captcha)) )) {
)
) {
throw new ActionError({ throw new ActionError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Invalid Captcha Token.", message: "Invalid Captcha Token.",
@ -96,7 +109,19 @@ const submitActionDefinition = {
} }
if (input.action === "send_otp") { if (input.action === "send_otp") {
const { name, phone, msg } = input; const { name, msg } = input;
const phone = normalizePhone(input.phone);
if (
Otp.isRateLimitedGlobally() ||
Otp.isRateLimitedForOtp(phone) ||
Otp.isRateLimitedForMsgs(phone)
) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Too many requests. Try again later.",
});
}
const otp = Otp.generateOtp(phone, OTP_SUPER_SECRET_SALT); const otp = Otp.generateOtp(phone, OTP_SUPER_SECRET_SALT);
const stepSeconds = Otp.getOtpStep(); const stepSeconds = Otp.getOtpStep();
@ -107,6 +132,9 @@ const submitActionDefinition = {
remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "." remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "."
}`; }`;
Otp.recordOtpRequest(phone);
Otp.recordGlobalOtpSend();
const result = await new SmsClient().sendSMS(phone, message); const result = await new SmsClient().sendSMS(phone, message);
if (result.success) { if (result.success) {
@ -118,15 +146,16 @@ const submitActionDefinition = {
nextAction: "send_msg", nextAction: "send_msg",
}; };
} else { } else {
console.error("OTP SMS send failed:", result.message);
throw new ActionError({ throw new ActionError({
code: "SERVICE_UNAVAILABLE", code: "SERVICE_UNAVAILABLE",
message: "Verification code failed to send: " + result.message, message: "Verification code failed to send. Try again later.",
}); });
} }
} else if (input.action === "send_msg") { } else if (input.action === "send_msg") {
const { otp } = input; const { otp } = input;
const name = await context.session?.get("name"); const name = await context.session?.get("name");
const phone = await context.session?.get("phone"); const phone = normalizePhone((await context.session?.get("phone")) ?? "");
const msg = await context.session?.get("msg"); const msg = await context.session?.get("msg");
if (!name || !otp || !msg || !phone) { if (!name || !otp || !msg || !phone) {
@ -136,20 +165,38 @@ const submitActionDefinition = {
}); });
} }
if (Otp.isRateLimitedForOtp(phone) || Otp.isRateLimitedForMsgs(phone)) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Too many requests. Try again later.",
});
}
const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp); const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp);
if (!isVerified) { if (!isVerified) {
const attempts = ((await context.session?.get("otpAttempts")) ?? 0) + 1;
if (attempts >= MAX_OTP_VERIFY_ATTEMPTS) {
context.session?.delete("phone");
context.session?.delete("name");
context.session?.delete("msg");
context.session?.delete("otpAttempts");
return {
nextAction: "send_otp",
error: "Too many incorrect codes. Please start over.",
field: "otp",
};
}
context.session?.set("otpAttempts", attempts);
return { return {
nextAction: "send_msg", nextAction: "send_msg",
error: "Invalid or expired verification code.", error: "Invalid or expired verification code.",
field: "otp", field: "otp",
}; };
// throw new ActionError({
// code: "BAD_REQUEST",
// message: "Invalid or expired verification code.",
// });
} }
const message = `Web message from ${name} ( ${phone} ):\n\n${msg}`; const message = `Web message from ${name} (${phone}):\n\n${msg}`;
Otp.recordMsgSubmission(phone);
const smsClient = new SmsClient(); const smsClient = new SmsClient();
const result = await smsClient.sendSMS( const result = await smsClient.sendSMS(
@ -158,8 +205,6 @@ const submitActionDefinition = {
); );
if (result.success) { if (result.success) {
Otp.recordMsgSubmission(phone);
context.session?.delete("phone"); context.session?.delete("phone");
context.session?.delete("name"); context.session?.delete("name");
context.session?.delete("msg"); context.session?.delete("msg");
@ -167,20 +212,21 @@ const submitActionDefinition = {
return { return {
nextAction: "complete", nextAction: "complete",
}; };
} else if (input.action === "reset") {
context.session?.delete("phone");
context.session?.delete("name");
context.session?.delete("msg");
return {
nextAction: "send_otp",
};
} }
console.error("Contact SMS send failed:", result.message);
throw new ActionError({ throw new ActionError({
code: "SERVICE_UNAVAILABLE", code: "SERVICE_UNAVAILABLE",
message: "Message failed to send.", message: "Message failed to send.",
}); });
} else if (input.action === "reset") {
context.session?.delete("phone");
context.session?.delete("name");
context.session?.delete("msg");
return {
nextAction: "send_otp",
};
} }
}, },
}; };

View file

@ -1,16 +1,44 @@
import { authenticator } from "otplib"; import { authenticator } from "otplib";
import { createHash } from "crypto"; import { createHash } from "crypto";
const submissionTimestamps = new Map(); const submissionTimestamps = new Map<string, number[]>();
const otpRequestTimestamps = new Map(); const otpRequestTimestamps = new Map<string, number[]>();
const ONE_WEEK_IN_MS: number = 7 * 24 * 60 * 60 * 1000; const ONE_WEEK_IN_MS: number = 7 * 24 * 60 * 60 * 1000;
const ONE_HOUR_IN_MS: number = 60 * 60 * 1000; const ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
const MAX_OTP_REQUESTS_PER_HOUR: number = 3; const MAX_OTP_REQUESTS_PER_HOUR: number = 3;
const MAX_MESSAGES_PER_WEEK: number = 3; const MAX_MESSAGES_PER_WEEK: number = 3;
const OTP_STEP_IN_SEC: number = 300; const OTP_STEP_IN_SEC: number = 300;
const VALID_PAST_OTP_STEPS: number = 1; const VALID_PAST_OTP_STEPS: number = 1;
const VALID_FUTURE_OTP_STEPS: number = 1; const VALID_FUTURE_OTP_STEPS: number = 0;
const OTP_NUM_DIGITS: number = 6; const OTP_NUM_DIGITS: number = 6;
const MAX_GLOBAL_OTP_SENDS_PER_HOUR: number = 10;
let globalOtpSendTimestamps: number[] = [];
export function isRateLimitedGlobally(): boolean {
const now = Date.now();
globalOtpSendTimestamps = globalOtpSendTimestamps.filter(
(t) => now - t < ONE_HOUR_IN_MS,
);
return globalOtpSendTimestamps.length >= MAX_GLOBAL_OTP_SENDS_PER_HOUR;
}
export function recordGlobalOtpSend() {
globalOtpSendTimestamps.push(Date.now());
}
function cleanupStaleEntries(map: Map<string, number[]>, windowMs: number) {
const now = Date.now();
for (const [key, timestamps] of map) {
const recent = timestamps.filter((t) => now - t < windowMs);
if (recent.length === 0) map.delete(key);
else if (recent.length !== timestamps.length) map.set(key, recent);
}
}
setInterval(() => {
cleanupStaleEntries(submissionTimestamps, ONE_WEEK_IN_MS);
cleanupStaleEntries(otpRequestTimestamps, ONE_HOUR_IN_MS);
}, ONE_HOUR_IN_MS).unref?.();
authenticator.options = { authenticator.options = {
step: OTP_STEP_IN_SEC, step: OTP_STEP_IN_SEC,
@ -30,15 +58,14 @@ function getUserSecret(phoneNumber: string, salt: string): string {
} }
export function normalizePhone(phone: string) { export function normalizePhone(phone: string) {
const result = phone.replace(/[^\d]/g, "").trim().startsWith("1") let digits = phone.replace(/\D/g, "");
? phone.substring(1) if (digits.length === 11 && digits.startsWith("1")) {
: phone; digits = digits.slice(1);
}
if (result.length !== 10) { if (digits.length !== 10) {
throw new Error("Invalid phone number."); throw new Error("Invalid phone number.");
} }
return digits;
return result;
} }
export function isValidPhone(phone: string): boolean { export function isValidPhone(phone: string): boolean {
@ -46,9 +73,13 @@ export function isValidPhone(phone: string): boolean {
const match = phone.match(/(\d{3})(\d{3})(\d{4})/); const match = phone.match(/(\d{3})(\d{3})(\d{4})/);
const [, prefix, exchange, station] = match ?? []; const [, prefix, exchange, station] = match ?? [];
const isValidNANPFormat = const isValidNANPFormat =
/^[2-7][0-8][0-9]$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange); /^[2-9][0-9]{2}$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange);
const isNotAllSameDigit = !/^(.)\1{6}$/.test(exchange + station); const isNotAllSameDigit = !/^(.)\1{6}$/.test(exchange + station);
const isNot911Number = prefix !== "911" && exchange !== "911"; const isNot911Number = prefix !== "911" && exchange !== "911";
const isNotTollFreeNumber = !(
/^[8-9][0-9]{2}$/.test(prefix) &&
/^(99|88|77|66|55|44|33|22|11|00)$/.test(prefix.slice(1, 2))
);
const isNot555Number = prefix !== "555" && exchange !== "555"; const isNot555Number = prefix !== "555" && exchange !== "555";
const isNotPopSongNumber = exchange !== "867" && station !== "5309"; const isNotPopSongNumber = exchange !== "867" && station !== "5309";
@ -57,6 +88,7 @@ export function isValidPhone(phone: string): boolean {
isNotAllSameDigit && isNotAllSameDigit &&
isNot911Number && isNot911Number &&
isNot555Number && isNot555Number &&
isNotTollFreeNumber &&
isNotPopSongNumber isNotPopSongNumber
); );
} }
@ -153,4 +185,6 @@ export default {
recordMsgSubmission, recordMsgSubmission,
isRateLimitedForOtp, isRateLimitedForOtp,
isRateLimitedForMsgs, isRateLimitedForMsgs,
isRateLimitedGlobally,
recordGlobalOtpSend,
}; };

View file

@ -1,7 +1,29 @@
import { defineMiddleware } from "astro:middleware"; import { defineMiddleware } from "astro:middleware";
import { getActionContext } from "astro:actions"; import { getActionContext } from "astro:actions";
import type { APIContext, MiddlewareNext } from "astro";
export const onRequest = defineMiddleware(async (context, next) => { // htmz -> frame-ancestors 'self' & X-Frame-Options SAMEORIGIN
// astro -> 'unsafe-inline'
const SECURITY_HEADERS: Record<string, string> = {
"Content-Security-Policy": [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://badblocks.goatcounter.com",
"font-src 'self'",
"connect-src 'self' https://badblocks.goatcounter.com https://api.iconify.design",
"object-src 'none'",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
};
async function handle(context: APIContext, next: MiddlewareNext) {
if (context.isPrerendered) return next(); if (context.isPrerendered) return next();
const { action, setActionResult, serializeActionResult } = const { action, setActionResult, serializeActionResult } =
@ -10,10 +32,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
const currentAction = await context.session?.get("currentAction"); const currentAction = await context.session?.get("currentAction");
if (currentAction) { if (currentAction) {
const { actionName, actionResult } = JSON.parse(currentAction);
setActionResult(actionName, actionResult);
context.session?.delete("currentAction"); context.session?.delete("currentAction");
try {
const { actionName, actionResult } = JSON.parse(currentAction);
setActionResult(actionName, actionResult);
} catch {}
return next(); return next();
} }
@ -39,13 +62,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
context.session?.set("contactFormDraft", draft); context.session?.set("contactFormDraft", draft);
const referer = context.request.headers.get("Referer"); let redirectPath = context.originPathname;
if (!referer) { try {
throw new Error( const referer = new URL(context.request.headers.get("Referer") ?? "");
"Internal: Referer unexpectedly missing from Action POST request.", if (referer.origin === context.url.origin) {
); redirectPath = referer.pathname;
} }
return context.redirect(referer); } catch {}
return context.redirect(redirectPath);
} }
context.session?.delete("contactFormDraft"); context.session?.delete("contactFormDraft");
@ -53,4 +77,12 @@ export const onRequest = defineMiddleware(async (context, next) => {
} }
return next(); return next();
}
export const onRequest = defineMiddleware(async (context, next) => {
const response = await handle(context, next);
for (const [header, value] of Object.entries(SECURITY_HEADERS)) {
response.headers.set(header, value);
}
return response;
}); });

8
src/pages/ai.astro Normal file
View file

@ -0,0 +1,8 @@
---
import Layout from "@layouts/BaseLayout.astro";
---
<Layout>
<title slot="head">AI Policy</title>
<Fragment slot="main"> </Fragment>
</Layout>

View file

@ -10,8 +10,21 @@ export const POST: APIRoute = async (context) => {
); );
} }
const { token, solutions } = await context.request.json(); let body: { token?: unknown; solutions?: unknown };
if (!token || !solutions) { try {
body = await context.request.json();
} catch {
return new Response(JSON.stringify({ success: false }), { status: 400 });
}
const { token, solutions } = body ?? {};
if (
typeof token !== "string" ||
token.length > 256 ||
!Array.isArray(solutions) ||
solutions.length > 128 ||
!solutions.every((s) => typeof s === "number")
) {
return new Response(JSON.stringify({ success: false }), { status: 400 }); return new Response(JSON.stringify({ success: false }), { status: 400 });
} }

View file

@ -168,8 +168,11 @@ const msgValue = pickValue("msg");
name="name" name="name"
aria-describedby="name" aria-describedby="name"
placeholder="Alice Bob" placeholder="Alice Bob"
value={nameValue}
/> />
{error.name && <p id="error_name">{error.name.join(",")}</p>} {"name" in error && error.name && (
<p id="error_name">{error.name}</p>
)}
</label> </label>
<label for="phone"> <label for="phone">
Phone Phone
@ -179,21 +182,26 @@ const msgValue = pickValue("msg");
name="phone" name="phone"
aria-describedby="error_phone" aria-describedby="error_phone"
placeholder="555-555-5555" placeholder="555-555-5555"
value={phoneValue}
/> />
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>} {"phone" in error && error.phone && (
<p id="error_phone">{error.phone}</p>
)}
</label> </label>
<label for="msg"> <label for="msg">
Msg Msg
<div class="textarea-wrapper"> <div class="textarea-wrapper" data-replicated-value={msgValue}>
<textarea <textarea
id="msg" id="msg"
name="msg" name="msg"
oninput="this.parentNode.dataset.replicatedValue = this.value" oninput="this.parentNode.dataset.replicatedValue = this.value"
aria-describedby="error_msg" aria-describedby="error_msg"
placeholder="I think badblocks rocks! Lorem ipsum dolor sit amet, consectetur adipiscing elit." placeholder="I think badblocks rocks! Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/> >
{msgValue}
</textarea>
</div> </div>
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>} {"msg" in error && error.msg && <p id="error_msg">{error.msg}</p>}
</label> </label>
<button <button
id="send_otp" id="send_otp"
@ -215,7 +223,7 @@ const msgValue = pickValue("msg");
name="otp" name="otp"
aria-describedby="error_otp" aria-describedby="error_otp"
/> />
{error.otp && <p id="error_otp">{error.otp.join(",")}</p>} {"otp" in error && error.otp && <p id="error_otp">{error.otp}</p>}
</label> </label>
<button <button
id="reset" id="reset"

View file

@ -6,7 +6,7 @@ import Layout from "@layouts/BaseLayout.astro";
<title slot="head">Home</title> <title slot="head">Home</title>
<Fragment slot="main"> <Fragment slot="main">
<article id="hero"> <article id="hero">
<h2>Under Construction</h2> <h2>It's badblocks!</h2>
<p>Pardon the dust!</p> <p>Pardon the dust!</p>
</article> </article>
</Fragment> </Fragment>