diff --git a/.dockerignore b/.dockerignore index a1c1a89..60f958b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,4 @@ docker-compose* README.md LICENSE .vscode -.env +.env.* diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml new file mode 100644 index 0000000..274e94a --- /dev/null +++ b/.forgejo/workflows/build-and-deploy.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index d50b2d7..c3a7015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -24,10 +24,10 @@ ENV NODE_ENV=production #RUN bun test 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 # (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 diff --git a/LICENSE b/LICENSE index 3dd6b7c..e027e8c 100644 --- a/LICENSE +++ b/LICENSE @@ -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 +Copyright Martin Tournoij 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 diff --git a/astro.config.mjs b/astro.config.mjs index 9c6cf56..427afd4 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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({ - ttl: 3600, - maxEntries: 1000, - }), + 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, }, }); diff --git a/bun.lockb b/bun.lockb index c5f177c..e82ed00 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cicd/scripts/build.sh b/cicd/scripts/build.sh new file mode 100755 index 0000000..8c105a4 --- /dev/null +++ b/cicd/scripts/build.sh @@ -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 diff --git a/cicd/scripts/deploy.sh b/cicd/scripts/deploy.sh new file mode 100755 index 0000000..2dec7ad --- /dev/null +++ b/cicd/scripts/deploy.sh @@ -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 <&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" diff --git a/deploy/certs/renewal-hooks/deploy/merge.sh b/deploy/certs/renewal-hooks/deploy/merge.sh new file mode 100644 index 0000000..c71d27b --- /dev/null +++ b/deploy/certs/renewal-hooks/deploy/merge.sh @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index fa39057..f752649 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: + - proxynet + # healthcheck: + # test: ["CMD", "haproxy", "-c", "-f", "/usr/local/etc/haproxy"] + # interval: 30s + # timeout: 10s + # retries: 3 networks: - default: + proxynet: name: proxynet - external: true + driver: bridge diff --git a/deploy/haproxy/challenge.html b/deploy/haproxy/challenge.html new file mode 100644 index 0000000..73eeca0 --- /dev/null +++ b/deploy/haproxy/challenge.html @@ -0,0 +1,143 @@ + + +Challenge Accepted! + +
+ + + + +
+
+ +
+
+ + + diff --git a/deploy/haproxy/haproxy.cfg b/deploy/haproxy/haproxy.cfg new file mode 100644 index 0000000..c3a6d7b --- /dev/null +++ b/deploy/haproxy/haproxy.cfg @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 81c2714..cffe336 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: personal-site: build: . ports: - - 127.0.0.1:4321:4321 + - 4321:4321 restart: no logging: options: diff --git a/package.json b/package.json index 09296bd..89a5b01 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/.well-known/mta-sts.txt b/public/.well-known/mta-sts.txt deleted file mode 100644 index bcf6c8e..0000000 --- a/public/.well-known/mta-sts.txt +++ /dev/null @@ -1,6 +0,0 @@ -version: STSv1 -mode: enforce -mx: mx.zoho.com -mx: mx2.zoho.com -mx: mx3.zoho.com -max_age: 604800 diff --git a/public/crt.svg b/public/crt.svg deleted file mode 100644 index 40b88e8..0000000 --- a/public/crt.svg +++ /dev/null @@ -1,6 +0,0 @@ - - CRT Icon - An icon of a CRT monitor, representing retro technology. - - - diff --git a/public/hi-res.svg b/public/hi-res.svg deleted file mode 100644 index 32862e0..0000000 --- a/public/hi-res.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Hi-Res Icon - An icon of a modern monitor, indicating high resolution. - - - - - - - diff --git a/src/actions/contact.ts b/src/actions/contact.ts index b908a72..b69426c 100644 --- a/src/actions/contact.ts +++ b/src/actions/contact.ts @@ -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() + .min(25) + .max(512) .transform(stripDisallowedCharacters) - .pipe( - z - .string() - .min(25) - .max(512) - - .refine(...noYelling) - .refine(...noExcessiveRepetitions), - ), + .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, - 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 (!( - /^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(input.captcha) && - (await cap.validateToken(input.captcha)) - )) { + 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,38 +136,20 @@ 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 message = `Web message from ${name} ( ${phone} ):\n\n${msg}`; const smsClient = new SmsClient(); const result = await smsClient.sendSMS( @@ -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,21 +167,20 @@ const submitActionDefinition = { return { 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({ code: "SERVICE_UNAVAILABLE", 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", - }; } }, }; diff --git a/src/lib/Otp.ts b/src/lib/Otp.ts index 31373d2..347a02d 100644 --- a/src/lib/Otp.ts +++ b/src/lib/Otp.ts @@ -1,44 +1,16 @@ import { authenticator } from "otplib"; import { createHash } from "crypto"; -const submissionTimestamps = new Map(); -const otpRequestTimestamps = new Map(); +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, 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, }; diff --git a/src/middleware.ts b/src/middleware.ts index 0621cf3..a97291a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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 = { - "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) { + const { actionName, actionResult } = JSON.parse(currentAction); + setActionResult(actionName, actionResult); + context.session?.delete("currentAction"); - try { - const { actionName, actionResult } = JSON.parse(currentAction); - setActionResult(actionName, actionResult); - } catch {} 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; - } - } catch {} - return context.redirect(redirectPath); + const referer = context.request.headers.get("Referer"); + if (!referer) { + throw new Error( + "Internal: Referer unexpectedly missing from Action POST request.", + ); + } + 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; }); diff --git a/src/pages/ai.astro b/src/pages/ai.astro deleted file mode 100644 index 4122804..0000000 --- a/src/pages/ai.astro +++ /dev/null @@ -1,8 +0,0 @@ ---- -import Layout from "@layouts/BaseLayout.astro"; ---- - - - AI Policy - - diff --git a/src/pages/cap/redeem.ts b/src/pages/cap/redeem.ts index e07e650..ed93e1c 100644 --- a/src/pages/cap/redeem.ts +++ b/src/pages/cap/redeem.ts @@ -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 }); } diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 67f89d1..933fb9e 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -168,11 +168,8 @@ const msgValue = pickValue("msg"); name="name" aria-describedby="name" placeholder="Alice Bob" - value={nameValue} /> - {"name" in error && error.name && ( -

{error.name}

- )} + {error.name &&

{error.name.join(",")}

}