Compare commits
1 commit
07dda5bc38
...
ae34b59676
| Author | SHA1 | Date | |
|---|---|---|---|
| ae34b59676 |
25 changed files with 590 additions and 273 deletions
|
|
@ -9,4 +9,4 @@ docker-compose*
|
||||||
README.md
|
README.md
|
||||||
LICENSE
|
LICENSE
|
||||||
.vscode
|
.vscode
|
||||||
.env
|
.env.*
|
||||||
|
|
|
||||||
72
.forgejo/workflows/build-and-deploy.yml
Normal file
72
.forgejo/workflows/build-and-deploy.yml
Normal 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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM oven/bun:1@sha256:50317d83cd5a5ae1d8b35b3379c69f57ce1a0dbf4def91f0965653d767851834 AS base
|
FROM oven/bun:1 AS base
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# install dependencies into temp directory
|
# install dependencies into temp directory
|
||||||
|
|
@ -24,10 +24,10 @@ ENV NODE_ENV=production
|
||||||
#RUN bun test
|
#RUN bun test
|
||||||
RUN bun run build-remote
|
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:24-slim@sha256:44a253901c13323953b4a0e1df5afe08bf8ac4ac086edd500422b54cb6e6eeb0 AS release
|
FROM node:20-slim 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
|
||||||
|
|
|
||||||
34
LICENSE
34
LICENSE
|
|
@ -1,7 +1,7 @@
|
||||||
Copyright 2026 badblocks
|
Copyright 2026 badblocks
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
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
|
the Software without restriction, including without limitation the rights to
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
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
|
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
|
The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
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,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
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.
|
SOFTWARE.
|
||||||
|
|
||||||
This software also contains various permissively-licensed 3rd-party
|
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
|
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
|
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
|
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
|
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||||
THIS SOFTWARE.
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import htmx from "astro-htmx";
|
import htmx from "astro-htmx";
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig, sessionDrivers, envField } from "astro/config";
|
import { defineConfig, 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,11 +23,13 @@ export default defineConfig({
|
||||||
prefetch: {
|
prefetch: {
|
||||||
prefetchAll: true,
|
prefetchAll: true,
|
||||||
},
|
},
|
||||||
|
security: {
|
||||||
|
checkOrigin: false,
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
driver: sessionDrivers.lruCache({
|
driver: "lru-cache",
|
||||||
ttl: 3600,
|
ttl: 3600,
|
||||||
maxEntries: 1000,
|
maxEntries: 1000,
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
|
|
@ -59,6 +61,8 @@ 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
BIN
bun.lockb
Binary file not shown.
30
cicd/scripts/build.sh
Executable file
30
cicd/scripts/build.sh
Executable 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
47
cicd/scripts/deploy.sh
Executable 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"
|
||||||
4
deploy/certs/renewal-hooks/deploy/merge.sh
Normal file
4
deploy/certs/renewal-hooks/deploy/merge.sh
Normal 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
|
||||||
|
|
@ -5,22 +5,25 @@ 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:
|
||||||
[
|
# [
|
||||||
"node",
|
# "CMD",
|
||||||
"-e",
|
# "curl",
|
||||||
"fetch('http://localhost:4321/health').then(r=>process.exit(r.ok?0:1))",
|
# "-f",
|
||||||
]
|
# "-s",
|
||||||
interval: 30s
|
# "--max-time",
|
||||||
timeout: 15s
|
# "5",
|
||||||
retries: 3
|
# "http://localhost:4321/health",
|
||||||
start_period: 120s
|
# ]
|
||||||
cap_drop: [ALL]
|
# interval: 30s
|
||||||
security_opt: [no-new-privileges:true]
|
# timeout: 15s
|
||||||
read_only: true
|
# retries: 3
|
||||||
|
# start_period: 120s
|
||||||
wireguard:
|
wireguard:
|
||||||
image: qmcgaw/gluetun
|
image: qmcgaw/gluetun
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -38,13 +41,46 @@ 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:
|
||||||
default:
|
proxynet:
|
||||||
name: proxynet
|
name: proxynet
|
||||||
external: true
|
driver: bridge
|
||||||
|
|
|
||||||
143
deploy/haproxy/challenge.html
Normal file
143
deploy/haproxy/challenge.html
Normal 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
|
||||||
|
>
|
||||||
85
deploy/haproxy/haproxy.cfg
Normal file
85
deploy/haproxy/haproxy.cfg
Normal 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
|
||||||
|
|
@ -2,7 +2,7 @@ services:
|
||||||
personal-site:
|
personal-site:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:4321:4321
|
- 4321:4321
|
||||||
restart: no
|
restart: no
|
||||||
logging:
|
logging:
|
||||||
options:
|
options:
|
||||||
|
|
|
||||||
34
package.json
34
package.json
|
|
@ -14,34 +14,34 @@
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/alpinejs": "^1.0.0",
|
"@astrojs/alpinejs": "^0.4.9",
|
||||||
"@astrojs/check": "^0.9.9",
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/db": "^0.21.3",
|
"@astrojs/db": "^0.18.3",
|
||||||
"@astrojs/node": "^11.0.2",
|
"@astrojs/node": "^9.5.4",
|
||||||
"@astrojs/partytown": "^2.1.7",
|
"@astrojs/partytown": "^2.1.4",
|
||||||
"@astrojs/sitemap": "^3.7.3",
|
"@astrojs/sitemap": "^3.7.0",
|
||||||
"@astrojs/ts-plugin": "^1.10.10",
|
"@astrojs/ts-plugin": "^1.10.6",
|
||||||
"@cap.js/server": "^4.0.5",
|
"@cap.js/server": "^4.0.5",
|
||||||
"@cap.js/widget": "^0.1.56",
|
"@cap.js/widget": "^0.1.35",
|
||||||
"@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.12",
|
"alpinejs": "^3.15.8",
|
||||||
"android-sms-gateway": "^3.0.0",
|
"android-sms-gateway": "^3.0.0",
|
||||||
"astro": "^7.0.6",
|
"astro": "^5.18.0",
|
||||||
"astro-htmx": "^1.0.6",
|
"astro-htmx": "^1.0.6",
|
||||||
"htmx.org": "^2.0.10",
|
"htmx.org": "^2.0.8",
|
||||||
"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": "^6.0.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "^8.6.0",
|
"undici": "^7.22.0",
|
||||||
"validator": "^13.15.35"
|
"validator": "^13.15.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.9",
|
||||||
"@types/validator": "^13.15.10",
|
"@types/validator": "^13.15.10",
|
||||||
"doiuse": "^6.0.6",
|
"doiuse": "^6.0.6",
|
||||||
"prettier": "^3.9.4",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-astro": "^0.14.1"
|
"prettier-plugin-astro": "^0.14.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
version: STSv1
|
|
||||||
mode: enforce
|
|
||||||
mx: mx.zoho.com
|
|
||||||
mx: mx2.zoho.com
|
|
||||||
mx: mx3.zoho.com
|
|
||||||
max_age: 604800
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -3,15 +3,13 @@ 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, normalizePhone } from "@lib/Otp.ts";
|
import Otp, { verifyOtp } 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"]) &&
|
||||||
|
|
@ -41,11 +39,7 @@ 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
|
name: z.string().trim().min(5).max(32).transform(stripDisallowedCharacters),
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.transform(stripDisallowedCharacters)
|
|
||||||
.pipe(z.string().min(5).max(32)),
|
|
||||||
phone: z
|
phone: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -53,16 +47,11 @@ const sendOtpAction = z.object({
|
||||||
msg: z
|
msg: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
|
.min(25)
|
||||||
|
.max(512)
|
||||||
.transform(stripDisallowedCharacters)
|
.transform(stripDisallowedCharacters)
|
||||||
.pipe(
|
.refine(...noYelling)
|
||||||
z
|
.refine(...noExcessiveRepetitions),
|
||||||
.string()
|
|
||||||
.min(25)
|
|
||||||
.max(512)
|
|
||||||
|
|
||||||
.refine(...noYelling)
|
|
||||||
.refine(...noExcessiveRepetitions),
|
|
||||||
),
|
|
||||||
captcha: captcha_input,
|
captcha: captcha_input,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -74,7 +63,6 @@ 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", [
|
||||||
|
|
@ -85,10 +73,7 @@ const formAction = z.discriminatedUnion("action", [
|
||||||
|
|
||||||
const submitActionDefinition = {
|
const submitActionDefinition = {
|
||||||
input: formAction,
|
input: formAction,
|
||||||
handler: async (
|
handler: async (input: any, context: ActionAPIContext) => {
|
||||||
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",
|
||||||
|
|
@ -98,10 +83,12 @@ 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) &&
|
!(
|
||||||
(await cap.validateToken(input.captcha))
|
/^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(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.",
|
||||||
|
|
@ -109,19 +96,7 @@ const submitActionDefinition = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.action === "send_otp") {
|
if (input.action === "send_otp") {
|
||||||
const { name, msg } = input;
|
const { name, phone, 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();
|
||||||
|
|
@ -132,9 +107,6 @@ 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) {
|
||||||
|
|
@ -146,16 +118,15 @@ 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. Try again later.",
|
message: "Verification code failed to send: " + result.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} 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 = normalizePhone((await context.session?.get("phone")) ?? "");
|
const phone = 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) {
|
||||||
|
|
@ -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);
|
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(
|
||||||
|
|
@ -205,6 +158,8 @@ 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");
|
||||||
|
|
@ -212,21 +167,20 @@ 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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,16 @@
|
||||||
import { authenticator } from "otplib";
|
import { authenticator } from "otplib";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
const submissionTimestamps = new Map<string, number[]>();
|
const submissionTimestamps = new Map();
|
||||||
const otpRequestTimestamps = new Map<string, number[]>();
|
const otpRequestTimestamps = new Map();
|
||||||
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 = 0;
|
const VALID_FUTURE_OTP_STEPS: number = 1;
|
||||||
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,
|
||||||
|
|
@ -58,14 +30,15 @@ function getUserSecret(phoneNumber: string, salt: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePhone(phone: string) {
|
export function normalizePhone(phone: string) {
|
||||||
let digits = phone.replace(/\D/g, "");
|
const result = phone.replace(/[^\d]/g, "").trim().startsWith("1")
|
||||||
if (digits.length === 11 && digits.startsWith("1")) {
|
? phone.substring(1)
|
||||||
digits = digits.slice(1);
|
: phone;
|
||||||
}
|
|
||||||
if (digits.length !== 10) {
|
if (result.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 {
|
||||||
|
|
@ -73,13 +46,9 @@ 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-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 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";
|
||||||
|
|
||||||
|
|
@ -88,7 +57,6 @@ export function isValidPhone(phone: string): boolean {
|
||||||
isNotAllSameDigit &&
|
isNotAllSameDigit &&
|
||||||
isNot911Number &&
|
isNot911Number &&
|
||||||
isNot555Number &&
|
isNot555Number &&
|
||||||
isNotTollFreeNumber &&
|
|
||||||
isNotPopSongNumber
|
isNotPopSongNumber
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +153,4 @@ export default {
|
||||||
recordMsgSubmission,
|
recordMsgSubmission,
|
||||||
isRateLimitedForOtp,
|
isRateLimitedForOtp,
|
||||||
isRateLimitedForMsgs,
|
isRateLimitedForMsgs,
|
||||||
isRateLimitedGlobally,
|
|
||||||
recordGlobalOtpSend,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,7 @@
|
||||||
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";
|
|
||||||
|
|
||||||
// htmz -> frame-ancestors 'self' & X-Frame-Options SAMEORIGIN
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
// 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 } =
|
||||||
|
|
@ -32,11 +10,10 @@ async function handle(context: APIContext, next: MiddlewareNext) {
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,14 +39,13 @@ async function handle(context: APIContext, next: MiddlewareNext) {
|
||||||
|
|
||||||
context.session?.set("contactFormDraft", draft);
|
context.session?.set("contactFormDraft", draft);
|
||||||
|
|
||||||
let redirectPath = context.originPathname;
|
const referer = context.request.headers.get("Referer");
|
||||||
try {
|
if (!referer) {
|
||||||
const referer = new URL(context.request.headers.get("Referer") ?? "");
|
throw new Error(
|
||||||
if (referer.origin === context.url.origin) {
|
"Internal: Referer unexpectedly missing from Action POST request.",
|
||||||
redirectPath = referer.pathname;
|
);
|
||||||
}
|
}
|
||||||
} catch {}
|
return context.redirect(referer);
|
||||||
return context.redirect(redirectPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.session?.delete("contactFormDraft");
|
context.session?.delete("contactFormDraft");
|
||||||
|
|
@ -77,12 +53,4 @@ async function handle(context: APIContext, next: MiddlewareNext) {
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
import Layout from "@layouts/BaseLayout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<title slot="head">AI Policy</title>
|
|
||||||
<Fragment slot="main"> </Fragment>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -10,21 +10,8 @@ export const POST: APIRoute = async (context) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { token?: unknown; solutions?: unknown };
|
const { token, solutions } = await context.request.json();
|
||||||
try {
|
if (!token || !solutions) {
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,11 +168,8 @@ const msgValue = pickValue("msg");
|
||||||
name="name"
|
name="name"
|
||||||
aria-describedby="name"
|
aria-describedby="name"
|
||||||
placeholder="Alice Bob"
|
placeholder="Alice Bob"
|
||||||
value={nameValue}
|
|
||||||
/>
|
/>
|
||||||
{"name" in error && error.name && (
|
{error.name && <p id="error_name">{error.name.join(",")}</p>}
|
||||||
<p id="error_name">{error.name}</p>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<label for="phone">
|
<label for="phone">
|
||||||
Phone
|
Phone
|
||||||
|
|
@ -182,26 +179,21 @@ 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}
|
|
||||||
/>
|
/>
|
||||||
{"phone" in error && error.phone && (
|
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
|
||||||
<p id="error_phone">{error.phone}</p>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<label for="msg">
|
<label for="msg">
|
||||||
Msg
|
Msg
|
||||||
<div class="textarea-wrapper" data-replicated-value={msgValue}>
|
<div class="textarea-wrapper">
|
||||||
<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>
|
||||||
{"msg" in error && error.msg && <p id="error_msg">{error.msg}</p>}
|
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="send_otp"
|
id="send_otp"
|
||||||
|
|
@ -223,7 +215,7 @@ const msgValue = pickValue("msg");
|
||||||
name="otp"
|
name="otp"
|
||||||
aria-describedby="error_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>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="reset"
|
id="reset"
|
||||||
|
|
|
||||||
|
|
@ -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>It's badblocks!</h2>
|
<h2>Under Construction</h2>
|
||||||
<p>Pardon the dust!</p>
|
<p>Pardon the dust!</p>
|
||||||
</article>
|
</article>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
|
||||||
35
src/pages/license.astro
Normal file
35
src/pages/license.astro
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
.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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue