diff --git a/Dockerfile b/Dockerfile index c3a7015..d50b2d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/astro.config.mjs b/astro.config.mjs index 427afd4..6817098 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,18 +1,15 @@ -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({ site: "https://badblocks.dev", trailingSlash: "never", - // bun adapter is not official, so keep - // the node adapter available just in case + // bun adapter is not official, also a little wonky, so use node adapter for now adapter: node({ mode: "standalone", }), @@ -24,12 +21,12 @@ export default defineConfig({ prefetchAll: true, }, security: { - checkOrigin: false, + allowedDomains: [{ protocol: "https", hostname: "badblocks.dev" }], }, session: { - driver: "lru-cache", - ttl: 3600, - maxEntries: 1000, + driver: sessionDrivers.lruCache({ + ttl: 3600, + }), }, server: { host: true, @@ -59,10 +56,8 @@ export default defineConfig({ }), }, }, - integrations: [alpinejs(), sitemap(), htmx(), db()], + integrations: [alpinejs(), sitemap()], experimental: { - preserveScriptOrder: true, chromeDevtoolsWorkspace: true, - failOnPrerenderConflict: true, }, }); diff --git a/bun.lockb b/bun.lockb index e82ed00..3862f4f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..eeab30a --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/db/config.ts b/db/config.ts deleted file mode 100644 index dc78187..0000000 --- a/db/config.ts +++ /dev/null @@ -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 }, -}); diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index a4cbf23..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index cffe336..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - personal-site: - build: . - ports: - - 4321:4321 - restart: no - logging: - options: - max-size: 1g diff --git a/package.json b/package.json index 89a5b01..0f7e869 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/.well-known/mta-sts.txt b/public/.well-known/mta-sts.txt new file mode 100644 index 0000000..bcf6c8e --- /dev/null +++ b/public/.well-known/mta-sts.txt @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..40b88e8 --- /dev/null +++ b/public/crt.svg @@ -0,0 +1,6 @@ + + CRT Icon + An icon of a CRT monitor, representing retro technology. + + + diff --git a/public/goat.js b/public/goat.js index 8e06d8a..f35d27e 100644 --- a/public/goat.js +++ b/public/goat.js @@ -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(); diff --git a/public/hi-res.svg b/public/hi-res.svg new file mode 100644 index 0000000..32862e0 --- /dev/null +++ b/public/hi-res.svg @@ -0,0 +1,10 @@ + + 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 6822f4b..5e43f1a 100644 --- a/src/actions/contact.ts +++ b/src/actions/contact.ts @@ -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, + 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", diff --git a/src/lib/CapAdapter.ts b/src/lib/CapAdapter.ts index fa8a582..07959af 100644 --- a/src/lib/CapAdapter.ts +++ b/src/lib/CapAdapter.ts @@ -1,7 +1,7 @@ import Cap, { type ChallengeData } from "@cap.js/server"; import type { AstroSession } from "astro"; -export function createCap(session: AstroSession | null) { +export function createCap(session: AstroSession | null) { if (!session) { throw new Error("Session context is required"); } diff --git a/src/lib/HttpFetchClient.ts b/src/lib/HttpFetchClient.ts index 4319c5e..b5578e2 100644 --- a/src/lib/HttpFetchClient.ts +++ b/src/lib/HttpFetchClient.ts @@ -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) => { 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; diff --git a/src/lib/Otp.ts b/src/lib/Otp.ts index 347a02d..ce7d5b4 100644 --- a/src/lib/Otp.ts +++ b/src/lib/Otp.ts @@ -1,16 +1,44 @@ 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 = 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, 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, }; diff --git a/src/middleware.ts b/src/middleware.ts index a97291a..cb78178 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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 = { + "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; }); diff --git a/src/pages/ai.astro b/src/pages/ai.astro new file mode 100644 index 0000000..4122804 --- /dev/null +++ b/src/pages/ai.astro @@ -0,0 +1,8 @@ +--- +import Layout from "@layouts/BaseLayout.astro"; +--- + + + AI Policy + + diff --git a/src/pages/cap/redeem.ts b/src/pages/cap/redeem.ts index ed93e1c..e07e650 100644 --- a/src/pages/cap/redeem.ts +++ b/src/pages/cap/redeem.ts @@ -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 }); } diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 933fb9e..ae62a15 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -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"); @@ -167,9 +167,12 @@ const msgValue = pickValue("msg"); id="name" name="name" aria-describedby="name" - placeholder="Alice Bob" + placeholder="Alice Bobston" + value={nameValue} /> - {error.name && {error.name.join(",")}} + {"name" in error && error.name && ( + {error.name} + )} Phone @@ -179,21 +182,19 @@ const msgValue = pickValue("msg"); name="phone" aria-describedby="error_phone" placeholder="555-555-5555" + value={phoneValue} /> - {error.phone && {error.phone.join(",")}} + {"phone" in error && error.phone && ( + {error.phone} + )} Msg - - + + {/* prettier-ignore */} + {msgValue} - {error.msg && {error.msg.join(",")}} + {"msg" in error && error.msg && {error.msg}} - {error.otp && {error.otp.join(",")}} + {"otp" in error && error.otp && {error.otp}} Home - Under Construction + It's badblocks! Pardon the dust!
{error.name.join(",")}
{error.name}
{error.phone.join(",")}
{error.phone}
{error.msg.join(",")}
{error.msg}
{error.otp.join(",")}
{error.otp}
Pardon the dust!