Compare commits

..

1 commit

Author SHA1 Message Date
ae34b59676
Add license page, update LICENSE file, and link license page on the base
Some checks failed
Build And Deploy / build-and-deploy (push) Failing after 2m11s
layout instead of ext. MIT license.

Add src/pages/license.astro to render LICENSE with basic escaping
and newline-to-<br> handling. Update LICENSE formatting, and also add
new
third-party icon attributions.
2026-03-22 13:58:01 -07:00
25 changed files with 590 additions and 273 deletions

View file

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

View file

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

34
LICENSE
View file

@ -1,7 +1,7 @@
Copyright 2026 badblocks
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
@ -10,7 +10,7 @@ so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
@ -19,11 +19,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This software also contains various permissively-licensed 3rd-party
components, the licenses for which are listed below.
components, the licenses for which are listed below:
public/goat.js; ISC license
--- public/goat.js; ISC license ---
Copyright © Martin Tournoij <martin@arp242.net>
Copyright Martin Tournoij <martin@arp242.net>
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
@ -36,3 +36,27 @@ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
--- public/crt.svg; CC BY 3.0 license ---
Icon made by Delapouite: https://game-icons.net
Used under CC BY 3.0 https://creativecommons.org/licenses/by/3.0/
Modified from original
--- public/hi-res.svg; CC BY 4.0 license ---
Icon made by Streamline professional team: https://streamlinehq.com
Used under CC BY 4.0 https://creativecommons.org/licenses/by/4.0/
Modified from original
--- public/mit.svg; PD ---
Icon made by ZyMOS: https://commons.wikimedia.org/wiki/User:ZyMOS
Released by author into the Public Domain
Modified from original

View file

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

BIN
bun.lockb

Binary file not shown.

30
cicd/scripts/build.sh Executable file
View file

@ -0,0 +1,30 @@
#!/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

47
cicd/scripts/deploy.sh Executable file
View file

@ -0,0 +1,47 @@
#!/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

@ -0,0 +1,4 @@
#!/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,22 +5,25 @@ services:
container_name: badblocks-personal-site
volumes:
- ./db:/db
networks:
- proxynet
env_file:
- .env
healthcheck:
test:
[
"node",
"-e",
"fetch('http://localhost:4321/health').then(r=>process.exit(r.ok?0:1))",
]
interval: 30s
timeout: 15s
retries: 3
start_period: 120s
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
# healthcheck:
# test:
# [
# "CMD",
# "curl",
# "-f",
# "-s",
# "--max-time",
# "5",
# "http://localhost:4321/health",
# ]
# interval: 30s
# timeout: 15s
# retries: 3
# start_period: 120s
wireguard:
image: qmcgaw/gluetun
cap_add:
@ -38,13 +41,46 @@ services:
devices:
- /dev/net/tun:/dev/net/tun
restart: unless-stopped
networks:
- proxynet
# healthcheck:
# test: ["CMD", "ping", "-c", "1", "-W", "3", "$$ANDROID_SMS_GATEWAY_IP"]
# interval: 30s
# timeout: 15s
# retries: 3
# 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:
default:
- proxynet
# healthcheck:
# test: ["CMD", "haproxy", "-c", "-f", "/usr/local/etc/haproxy"]
# interval: 30s
# timeout: 10s
# retries: 3
networks:
proxynet:
name: proxynet
external: true
driver: bridge

View file

@ -0,0 +1,143 @@
<!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

@ -0,0 +1,85 @@
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:
build: .
ports:
- 127.0.0.1:4321:4321
- 4321:4321
restart: no
logging:
options:

View file

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

View file

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

View file

@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 743 B

View file

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

View file

@ -1,44 +1,16 @@
import { authenticator } from "otplib";
import { createHash } from "crypto";
const submissionTimestamps = new Map<string, number[]>();
const otpRequestTimestamps = new Map<string, number[]>();
const submissionTimestamps = new Map();
const otpRequestTimestamps = new Map();
const ONE_WEEK_IN_MS: number = 7 * 24 * 60 * 60 * 1000;
const ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
const MAX_OTP_REQUESTS_PER_HOUR: number = 3;
const MAX_MESSAGES_PER_WEEK: number = 3;
const OTP_STEP_IN_SEC: number = 300;
const VALID_PAST_OTP_STEPS: number = 1;
const VALID_FUTURE_OTP_STEPS: number = 0;
const VALID_FUTURE_OTP_STEPS: number = 1;
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 = {
step: OTP_STEP_IN_SEC,
@ -58,14 +30,15 @@ function getUserSecret(phoneNumber: string, salt: string): string {
}
export function normalizePhone(phone: string) {
let digits = phone.replace(/\D/g, "");
if (digits.length === 11 && digits.startsWith("1")) {
digits = digits.slice(1);
}
if (digits.length !== 10) {
const result = phone.replace(/[^\d]/g, "").trim().startsWith("1")
? phone.substring(1)
: phone;
if (result.length !== 10) {
throw new Error("Invalid phone number.");
}
return digits;
return result;
}
export function isValidPhone(phone: string): boolean {
@ -73,13 +46,9 @@ export function isValidPhone(phone: string): boolean {
const match = phone.match(/(\d{3})(\d{3})(\d{4})/);
const [, prefix, exchange, station] = match ?? [];
const isValidNANPFormat =
/^[2-9][0-9]{2}$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange);
/^[2-7][0-8][0-9]$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange);
const isNotAllSameDigit = !/^(.)\1{6}$/.test(exchange + station);
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 isNotPopSongNumber = exchange !== "867" && station !== "5309";
@ -88,7 +57,6 @@ export function isValidPhone(phone: string): boolean {
isNotAllSameDigit &&
isNot911Number &&
isNot555Number &&
isNotTollFreeNumber &&
isNotPopSongNumber
);
}
@ -185,6 +153,4 @@ export default {
recordMsgSubmission,
isRateLimitedForOtp,
isRateLimitedForMsgs,
isRateLimitedGlobally,
recordGlobalOtpSend,
};

View file

@ -1,29 +1,7 @@
import { defineMiddleware } from "astro:middleware";
import { getActionContext } from "astro:actions";
import type { APIContext, MiddlewareNext } from "astro";
// 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) {
export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) return next();
const { action, setActionResult, serializeActionResult } =
@ -32,11 +10,10 @@ async function handle(context: APIContext, next: MiddlewareNext) {
const currentAction = await context.session?.get("currentAction");
if (currentAction) {
context.session?.delete("currentAction");
try {
const { actionName, actionResult } = JSON.parse(currentAction);
setActionResult(actionName, actionResult);
} catch {}
context.session?.delete("currentAction");
return next();
}
@ -62,14 +39,13 @@ async function handle(context: APIContext, next: MiddlewareNext) {
context.session?.set("contactFormDraft", draft);
let redirectPath = context.originPathname;
try {
const referer = new URL(context.request.headers.get("Referer") ?? "");
if (referer.origin === context.url.origin) {
redirectPath = referer.pathname;
const referer = context.request.headers.get("Referer");
if (!referer) {
throw new Error(
"Internal: Referer unexpectedly missing from Action POST request.",
);
}
} catch {}
return context.redirect(redirectPath);
return context.redirect(referer);
}
context.session?.delete("contactFormDraft");
@ -77,12 +53,4 @@ async function handle(context: APIContext, next: MiddlewareNext) {
}
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;
});

View file

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

View file

@ -10,21 +10,8 @@ export const POST: APIRoute = async (context) => {
);
}
let body: { token?: unknown; solutions?: unknown };
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")
) {
const { token, solutions } = await context.request.json();
if (!token || !solutions) {
return new Response(JSON.stringify({ success: false }), { status: 400 });
}

View file

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

View file

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

35
src/pages/license.astro Normal file
View file

@ -0,0 +1,35 @@
---
import Layout from "@layouts/BaseLayout.astro";
import fs from "node:fs";
const txtToHtml = (string: string) => {
return string
.replace(/[^\x0A\x20-\x7E]/g, "") //remove non-printable ascii chars, except newline
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;")
.replaceAll("\n\n", "<br><br>");
};
const license_file_contents = txtToHtml(
fs.readFileSync("./LICENSE", {
encoding: "utf8",
}),
);
---
<Layout>
<title slot="head">LICENSE</title>
<Fragment slot="main">
<header>
<h1>
<a href="https://opensource.org/license/mit" target="_blank"
>MIT License</a
>
</h1>
</header>
<p set:html={license_file_contents} />
</Fragment>
</Layout>