This commit is contained in:
badblocks 2026-07-10 00:21:45 -07:00
parent 6e7977997e
commit e97ed713c5
Signed by: badblocks
SSH key fingerprint: SHA256:hEcM6BP4hKm9F7WsomNuXSBpPn2BSDnFzPSl3y1NerQ
21 changed files with 286 additions and 197 deletions

View file

@ -1,4 +1,4 @@
FROM oven/bun:1 AS base
FROM oven/bun:1@sha256:50317d83cd5a5ae1d8b35b3379c69f57ce1a0dbf4def91f0965653d767851834 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:20-slim AS release
FROM node:24-slim@sha256:44a253901c13323953b4a0e1df5afe08bf8ac4ac086edd500422b54cb6e6eeb0 AS release
WORKDIR /usr/src/app
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/dist ./dist

View file

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

BIN
bun.lockb

Binary file not shown.

34
compose.yaml Normal file
View file

@ -0,0 +1,34 @@
services:
badblocks-personal-site:
image: badblocks-personal-site:latest
restart: always
container_name: badblocks-personal-site
volumes:
- ./db:/db
environment:
ANDROID_SMS_GATEWAY_IP: ${ANDROID_SMS_GATEWAY_IP:-192.168.0.1}
ANDROID_SMS_GATEWAY_URL: ${ANDROID_SMS_GATEWAY_URL:-http://192.168.0.1:8080}
ANDROID_SMS_GATEWAY_LOGIN: ${ANDROID_SMS_GATEWAY_LOGIN:-sms}
ANDROID_SMS_GATEWAY_PASSWORD: ${ANDROID_SMS_GATEWAY_PASSWORD:-super-secret-password}
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE: ${ANDROID_SMS_GATEWAY_RECIPIENT_PHONE:-555-555-1234}
OTP_SUPER_SECRET_SALT: ${OTP_SUPER_SECRET_SALT:-super-secret-salt}
healthcheck:
test:
[
"CMD",
"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
tmpfs: [/tmp]
networks:
default:
name: proxynet
external: true

View file

@ -1,20 +0,0 @@
import { defineDb, defineTable, column } from "astro:db";
const Cap_Challenges = defineTable({
columns: {
token: column.text({ primaryKey: true }),
data: column.json(),
expires: column.number(),
},
});
const Cap_Tokens = defineTable({
columns: {
key: column.text({ primaryKey: true }),
expires: column.number(),
},
});
export default defineDb({
tables: { Cap_Challenges, Cap_Tokens },
});

View file

@ -1,51 +0,0 @@
services:
badblocks-personal-site:
image: ${IMAGE_NAME}:latest
restart: always
container_name: badblocks-personal-site
volumes:
- ./db:/db
env_file:
- .env
# 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:
- NET_ADMIN
container_name: wireguard
hostname: wireguard
environment:
- VPN_SERVICE_PROVIDER=custom
- VPN_TYPE=wireguard
- HTTPPROXY=on
expose:
- "8888"
env_file:
- .env
devices:
- /dev/net/tun:/dev/net/tun
restart: unless-stopped
# healthcheck:
# test: ["CMD", "ping", "-c", "1", "-W", "3", "$$ANDROID_SMS_GATEWAY_IP"]
# interval: 30s
# timeout: 15s
# retries: 3
# start_period: 60s
networks:
default:
name: proxynet
external: true

View file

@ -1,9 +0,0 @@
services:
personal-site:
build: .
ports:
- 4321:4321
restart: no
logging:
options:
max-size: 1g

View file

@ -14,34 +14,29 @@
"astro": "astro"
},
"dependencies": {
"@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",
"@astrojs/alpinejs": "^1.0.0",
"@astrojs/check": "^0.9.9",
"@astrojs/db": "^0.21.3",
"@astrojs/node": "^11.0.2",
"@astrojs/sitemap": "^3.7.3",
"@astrojs/ts-plugin": "^1.10.10",
"@cap.js/server": "^4.0.5",
"@cap.js/widget": "^0.1.35",
"@nurodev/astro-bun": "^2.1.2",
"@cap.js/widget": "^0.1.56",
"@types/alpinejs": "^3.13.11",
"alpinejs": "^3.15.8",
"alpinejs": "^3.15.12",
"android-sms-gateway": "^3.0.0",
"astro": "^5.18.0",
"astro-htmx": "^1.0.6",
"htmx.org": "^2.0.8",
"astro": "^7.0.7",
"iconify-icon": "^3.0.2",
"ofetch": "^1.5.1",
"otplib": "^12.0.1",
"typescript": "^5.9.3",
"undici": "^7.22.0",
"validator": "^13.15.26"
"otplib": "12.0.1",
"typescript": "^6.0.3",
"validator": "^13.15.35"
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/bun": "^1.3.14",
"@types/validator": "^13.15.10",
"doiuse": "^6.0.6",
"prettier": "^3.8.1",
"prettier": "^3.9.5",
"prettier-plugin-astro": "^0.14.1"
}
}

View file

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

6
public/crt.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -164,7 +164,7 @@
if (data.p === null)
// null from user callback.
return;
data.rnd = Math.random().toString(36).substr(2, 5); // Browsers don't always listen to Cache-Control.
data.rnd = Math.random().toString(36).slice(2, 5); // Browsers don't always listen to Cache-Control.
var endpoint = get_endpoint();
if (!endpoint) return warn("no endpoint found");
@ -202,10 +202,10 @@
// Get a query parameter.
window.goatcounter.get_query = function (name) {
var s = location.search.substr(1).split("&");
var s = location.search.slice(1).split("&");
for (var i = 0; i < s.length; i++)
if (s[i].toLowerCase().indexOf(name.toLowerCase() + "=") === 0)
return s[i].substr(name.length + 1);
return s[i].slice(name.length + 1);
};
// Track click events.
@ -308,7 +308,7 @@
)
goatcounter.count();
else {
var f = function (e) {
var f = function () {
if (document.visibilityState !== "visible") return;
document.removeEventListener("visibilitychange", f);
goatcounter.count();

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

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

After

Width:  |  Height:  |  Size: 743 B

View file

@ -3,13 +3,15 @@ import { z } from "astro/zod";
import type { ActionAPIContext } from "astro:actions";
import validator from "validator";
import SmsClient from "@lib/SmsGatewayClient.ts";
import Otp, { verifyOtp } from "@lib/Otp.ts";
import Otp, { verifyOtp, normalizePhone } from "@lib/Otp.ts";
import { createCap } from "@lib/CapAdapter";
import {
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"]) &&
@ -39,7 +41,11 @@ const captcha_input = z.string().trim().nonempty();
const sendOtpAction = z.object({
action: z.literal("send_otp"),
name: z.string().trim().min(5).max(32).transform(stripDisallowedCharacters),
name: z
.string()
.trim()
.transform(stripDisallowedCharacters)
.pipe(z.string().min(5).max(32)),
phone: z
.string()
.trim()
@ -47,11 +53,16 @@ const sendOtpAction = z.object({
msg: z
.string()
.trim()
.min(25)
.max(512)
.transform(stripDisallowedCharacters)
.refine(...noYelling)
.refine(...noExcessiveRepetitions),
.pipe(
z
.string()
.min(25)
.max(512)
.refine(...noYelling)
.refine(...noExcessiveRepetitions),
),
captcha: captcha_input,
});
@ -63,6 +74,7 @@ const sendMsgAction = z.object({
const resetAction = z.object({
action: z.literal("reset"),
captcha: captcha_input,
});
const formAction = z.discriminatedUnion("action", [
@ -73,7 +85,10 @@ const formAction = z.discriminatedUnion("action", [
const submitActionDefinition = {
input: formAction,
handler: async (input: any, context: ActionAPIContext) => {
handler: async (
input: z.infer<typeof formAction>,
context: ActionAPIContext,
) => {
if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) {
throw new ActionError({
code: "INTERNAL_SERVER_ERROR",
@ -83,12 +98,10 @@ 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.",
@ -96,7 +109,19 @@ const submitActionDefinition = {
}
if (input.action === "send_otp") {
const { name, phone, msg } = input;
const { name, msg } = input;
const phone = normalizePhone(input.phone);
if (
Otp.isRateLimitedGlobally() ||
Otp.isRateLimitedForOtp(phone) ||
Otp.isRateLimitedForMsgs(phone)
) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Too many requests. Try again later.",
});
}
const otp = Otp.generateOtp(phone, OTP_SUPER_SECRET_SALT);
const stepSeconds = Otp.getOtpStep();
@ -107,6 +132,9 @@ const submitActionDefinition = {
remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "."
}`;
Otp.recordOtpRequest(phone);
Otp.recordGlobalOtpSend();
const result = await new SmsClient().sendSMS(phone, message);
if (result.success) {
@ -118,38 +146,59 @@ 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: " + result.message,
message: "Verification code failed to send. Try again later.",
});
}
} else if (input.action === "send_msg") {
const { otp } = input;
const name = await context.session?.get("name");
const phone = await context.session?.get("phone");
const raw_phone = await context.session?.get("phone");
const msg = await context.session?.get("msg");
if (!name || !otp || !msg || !phone) {
if (!name || !otp || !msg || !raw_phone) {
throw new ActionError({
code: "BAD_REQUEST",
message: "Missing required fields.",
});
}
const phone = normalizePhone(raw_phone);
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}`;
const message = `Web message from ${name} (${phone}):\n\n${msg}`;
Otp.recordMsgSubmission(phone);
const smsClient = new SmsClient();
const result = await smsClient.sendSMS(
@ -158,17 +207,17 @@ const submitActionDefinition = {
);
if (result.success) {
Otp.recordMsgSubmission(phone);
context.session?.delete("phone");
context.session?.delete("name");
context.session?.delete("msg");
context.session?.delete("otpAttempts");
return {
nextAction: "complete",
};
}
console.error("Contact SMS send failed:", result.message);
throw new ActionError({
code: "SERVICE_UNAVAILABLE",
message: "Message failed to send.",
@ -177,6 +226,7 @@ const submitActionDefinition = {
context.session?.delete("phone");
context.session?.delete("name");
context.session?.delete("msg");
context.session?.delete("otpAttempts");
return {
nextAction: "send_otp",

View file

@ -1,7 +1,7 @@
import Cap, { type ChallengeData } from "@cap.js/server";
import type { AstroSession } from "astro";
export function createCap(session: AstroSession<any> | null) {
export function createCap(session: AstroSession | null) {
if (!session) {
throw new Error("Session context is required");
}

View file

@ -1,16 +1,10 @@
import { ofetch } from "ofetch";
import { ProxyAgent } from "undici";
const wireguardDispatcher = new ProxyAgent("http://wireguard:8888");
const TIMEOUT = 5000;
const httpFetchClient = {
get: async (url: string, headers: Record<string, string>) => {
const response = await ofetch(url, {
method: "GET",
headers,
dispatcher: wireguardDispatcher,
timeout: TIMEOUT,
});
return response;
@ -20,8 +14,6 @@ const httpFetchClient = {
method: "POST",
headers,
body: JSON.stringify(body),
dispatcher: wireguardDispatcher,
timeout: TIMEOUT,
});
return response;
@ -31,8 +23,6 @@ const httpFetchClient = {
method: "PUT",
headers,
body: JSON.stringify(body),
dispatcher: wireguardDispatcher,
timeout: TIMEOUT,
});
return response;
@ -42,8 +32,6 @@ const httpFetchClient = {
method: "PATCH",
headers,
body: JSON.stringify(body),
dispatcher: wireguardDispatcher,
timeout: TIMEOUT,
});
return response;
@ -52,8 +40,6 @@ const httpFetchClient = {
const response = await ofetch(url, {
method: "DELETE",
headers,
dispatcher: wireguardDispatcher,
timeout: TIMEOUT,
});
return response;

View file

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

View file

@ -1,7 +1,30 @@
import { defineMiddleware } from "astro:middleware";
import { getActionContext } from "astro:actions";
import type { APIContext, MiddlewareNext } from "astro";
export const onRequest = defineMiddleware(async (context, next) => {
// htmz -> frame-ancestors 'self' & X-Frame-Options SAMEORIGIN
// astro -> 'unsafe-inline'
const SECURITY_HEADERS: Record<string, string> = {
"Content-Security-Policy": [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"worker-src 'self' blob:",
"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 https://cdn.jsdelivr.net",
"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();
const { action, setActionResult, serializeActionResult } =
@ -10,10 +33,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
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();
}
@ -39,13 +63,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
context.session?.set("contactFormDraft", draft);
const referer = context.request.headers.get("Referer");
if (!referer) {
throw new Error(
"Internal: Referer unexpectedly missing from Action POST request.",
);
}
return context.redirect(referer);
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);
}
context.session?.delete("contactFormDraft");
@ -53,4 +78,12 @@ export const onRequest = defineMiddleware(async (context, next) => {
}
return next();
}
export const onRequest = defineMiddleware(async (context, next) => {
const response = await handle(context, next);
for (const [header, value] of Object.entries(SECURITY_HEADERS)) {
response.headers.set(header, value);
}
return response;
});

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

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

View file

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

View file

@ -52,7 +52,7 @@ const msgValue = pickValue("msg");
errorIcon &&
progressIcon
) {
cap.addEventListener("solve", function (e) {
cap.addEventListener("solve", function () {
const humanness = Math.round((85 + Math.random() * 14.9) * 10) / 10;
statusText.textContent = `${humanness}% human. Good enough!`;
progressIcon.classList.add("hidden");
@ -60,7 +60,7 @@ const msgValue = pickValue("msg");
initIcon.classList.add("hidden");
completeIcon.classList.remove("hidden");
});
cap.addEventListener("error", function (e) {
cap.addEventListener("error", function () {
statusText.textContent = "Oops! We crashed!";
progressIcon.classList.add("hidden");
completeIcon.classList.add("hidden");
@ -168,8 +168,11 @@ const msgValue = pickValue("msg");
name="name"
aria-describedby="name"
placeholder="Alice Bob"
value={nameValue}
/>
{error.name && <p id="error_name">{error.name.join(",")}</p>}
{"name" in error && error.name && (
<p id="error_name">{error.name}</p>
)}
</label>
<label for="phone">
Phone
@ -179,21 +182,19 @@ const msgValue = pickValue("msg");
name="phone"
aria-describedby="error_phone"
placeholder="555-555-5555"
value={phoneValue}
/>
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
{"phone" in error && error.phone && (
<p id="error_phone">{error.phone}</p>
)}
</label>
<label for="msg">
Msg
<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."
/>
<div class="textarea-wrapper" data-replicated-value={msgValue}>
{/* prettier-ignore */}
<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>
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
{"msg" in error && error.msg && <p id="error_msg">{error.msg}</p>}
</label>
<button
id="send_otp"
@ -215,7 +216,7 @@ const msgValue = pickValue("msg");
name="otp"
aria-describedby="error_otp"
/>
{error.otp && <p id="error_otp">{error.otp.join(",")}</p>}
{"otp" in error && error.otp && <p id="error_otp">{error.otp}</p>}
</label>
<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>Under Construction</h2>
<h2>It's badblocks!</h2>
<p>Pardon the dust!</p>
</article>
</Fragment>