From 72e57fb7ffa303dda1f81fb99c4e65487ad340c2 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:25:15 -0800 Subject: [PATCH] Break out contact form into separate component and implement OTP and rate limiting, add iconify-icon dependency Add src/components/ContactForm.astro implementing OTP verify/send flows and server-side validation Add src/lib/Otp.ts (otplib-backed) with per-hour OTP and per-week message rate limits and helper functions Expose OTP_SUPER_SECRET_SALT in astro.config and add otplib Rename src/lib/cap.ts to src/lib/CapAdapter.ts and update imports Replace inline contact page logic with ContactForm and adjust SMS client Add iconify-icon dependency --- astro.config.mjs | 4 + bun.lockb | Bin 203420 -> 206911 bytes package.json | 2 + src/components/ContactForm.astro | 351 ++++++++++++++++++++++++++++++ src/lib/{cap.ts => CapAdapter.ts} | 0 src/lib/Otp.ts | 152 +++++++++++++ src/lib/SmsGatewayClient.ts | 5 +- src/pages/cap/challenge.ts | 4 +- src/pages/cap/redeem.ts | 2 +- src/pages/contact.astro | 125 +---------- 10 files changed, 516 insertions(+), 129 deletions(-) create mode 100644 src/components/ContactForm.astro rename src/lib/{cap.ts => CapAdapter.ts} (100%) create mode 100644 src/lib/Otp.ts diff --git a/astro.config.mjs b/astro.config.mjs index 12087a7..63fff73 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -41,6 +41,10 @@ export default defineConfig({ context: "server", access: "secret", }), + OTP_SUPER_SECRET_SALT: envField.string({ + context: "server", + access: "secret", + }), }, }, integrations: [alpinejs(), sitemap(), htmx(), db()], diff --git a/bun.lockb b/bun.lockb index 25bfd62b08f7312171d83e0e8e000296d5a7f1c3..7c45111227f8bf745d4c3f61af8fa96800795885 100755 GIT binary patch delta 40161 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 delta 38545 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
  • }Kwb&U5#c84MWN37#!$^~9uqJIlC|)gq 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 X<=`&zCMeg%YZH~UJ)dPOqc;67z#@0@ diff --git a/package.json b/package.json index 9b4f6c5..bb2650a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "astro": "^5.16.6", "astro-htmx": "^1.0.6", "htmx.org": "^2.0.8", + "iconify-icon": "^3.0.2", + "otplib": "^12.0.1", "typescript": "^5.9.3" }, "devDependencies": { diff --git a/src/components/ContactForm.astro b/src/components/ContactForm.astro new file mode 100644 index 0000000..3eea6bb --- /dev/null +++ b/src/components/ContactForm.astro @@ -0,0 +1,351 @@ +--- +import SmsClient from "@lib/SmsGatewayClient.ts"; +import CapServer from "@lib/CapAdapter"; +import Otp, { verifyOtp } from "@lib/Otp.ts" +import { OTP_SUPER_SECRET_SALT, ANDROID_SMS_GATEWAY_RECIPIENT_PHONE } from "astro:env/server"; + +type FormErrors = { name: string; phone: string; msg: string; code: string; captcha: string; form: string }; + +type ValidationFailure = { success: false; message: string; field?: keyof FormErrors }; +type ValidationSuccess = { success: true; data: T; message?: string }; +type ValidationResult = ValidationSuccess | ValidationFailure; + +const OTP_SALT = OTP_SUPER_SECRET_SALT; +if (!OTP_SALT) { + throw new Error("OTP secret salt configuration is missing."); +} + +function makeSafeAndCheckPhoneNumber(unsafePhoneNumber: string): ValidationResult<{ phoneNumber: string }> { + const trimmed = unsafePhoneNumber.trim(); + const phoneNumberResult = Otp.validatePhoneNumber(trimmed); + + if (!phoneNumberResult.success || typeof phoneNumberResult.validatedPhoneNumber !== 'string') { + return { success: false, message: "Invalid phone number.", field: "phone" }; + } + + const { validatedPhoneNumber } = phoneNumberResult; + + if (Otp.isRateLimitedForOtp(validatedPhoneNumber)) { + return { success: false, message: "Too many OTP requests. Please try again later.", field: "phone" }; + } + + if (Otp.isRateLimitedForMsgs(validatedPhoneNumber)) { + return { success: false, message: "Too many messages. Please try again later.", field: "phone" }; + } + + return { success: true, data: { phoneNumber: validatedPhoneNumber } }; +} + +function makeSafeAndCheck(unsafeName: string, unsafePhoneNumber: string, unsafeCode: string, unsafeMsg: string): ValidationResult<{ name: string; phoneNumber: string; code: string; msg: string }> { + const phoneNumberResult = makeSafeAndCheckPhoneNumber(unsafePhoneNumber); + + if (!phoneNumberResult.success) { + return phoneNumberResult; + } + + const { phoneNumber } = phoneNumberResult.data; + const name = unsafeName.trim(); + const msg = unsafeMsg.trim(); + const code = unsafeCode.trim(); + + const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/; + const sixDigitsOnlyRegex = /^[0-9]{6}$/; + + if (!sixDigitsOnlyRegex.test(code)) { + return { success: false, message: "OTP code invalid.", field: "code" }; + } + + if (!printableAsciiRegex.test(name)) { + return { success: false, message: "Name contains non-ASCII or non-printable characters.", field: "name" }; + } + + if (!printableAsciiRegex.test(msg)) { + return { success: false, message: "Message contains non-ASCII or non-printable characters.", field: "msg" }; + } + + if (name.length < 2 || name.length > 25) { + return { success: false, message: "Please enter a valid name.", field: "name" }; + } + + if (msg.length > 500) { + return { success: false, message: "Message cannot be longer than 500 characters.", field: "msg" }; + } + + if (msg.length < 10) { + return { success: false, message: "Message is too short.", field: "msg" }; + } + + if (/([a-zA-Z])\1{4,}/.test(msg)) { + return { success: false, message: "Message contains excessive repeated characters.", field: "msg" }; + } + + const uppercaseRatio = (msg.match(/[A-Z]/g) || []).length / msg.length; + if (uppercaseRatio > 0.25) { + return { success: false, message: "Message contains excessive uppercase text.", field: "msg" }; + } + + return { success: true, data: { name, phoneNumber, code, msg } }; +} + +async function sendOtp(unsafePhoneNumber: string): Promise { + try { + const phoneNumberResult = makeSafeAndCheckPhoneNumber(unsafePhoneNumber); + + if (!phoneNumberResult.success) { + return phoneNumberResult; + } + + const { phoneNumber } = phoneNumberResult.data; + + const otp = Otp.generateOtp(phoneNumber, OTP_SALT); + const stepSeconds = Otp.getOtpStep(); + const stepMinutes = Math.floor(stepSeconds / 60); + const remainingSeconds = stepSeconds % 60; + + const api = new SmsClient(); + const message = `${otp} is your verification code. This code is valid for ${stepMinutes}m${remainingSeconds}s.`; + const result = await api.sendSMS(phoneNumber, message); + + if (result.success) { + Otp.recordOtpRequest(phoneNumber); + return { success: true, data: undefined, message: "Verification code sent successfully." }; + } + + throw new Error("Verification code failed to send."); + } catch (error: unknown) { + if (error instanceof Error) { + return { success: false, message: error.message, field: "form" }; + } + + return { success: false, message: "Verification code failed to send.", field: "form" }; + } +} + +async function sendMsg(unsafeName: string, unsafePhoneNumber: string, unsafeCode: string, unsafeMsg: string): Promise { + try { + const makeSafeResult = makeSafeAndCheck(unsafeName, unsafePhoneNumber, unsafeCode, unsafeMsg); + + if (!makeSafeResult.success) { + return makeSafeResult; + } + + const { name, phoneNumber, code, msg } = makeSafeResult.data; + const message = `Web message from ${name} ( ${phoneNumber} ):\n\n"${msg}"`; + + const isVerified = verifyOtp(phoneNumber, OTP_SALT, code); + if (!isVerified) { + return { success: false, message: "Your verification code is invalid or has expired. Please try again.", field: "code" }; + } + + const smsClient = new SmsClient(); + const result = await smsClient.sendSMS(ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, message); + + if (result.success) { + Otp.recordMsgSubmission(phoneNumber); + return { success: true, data: undefined, message: "Message sent successfully." }; + } + + throw new Error("Message failed to send."); + } catch (error: unknown) { + if (error instanceof Error) { + return { success: false, message: error.message, field: "form" }; + } + + return { success: false, message: "Message failed to send.", field: "form" }; + } +} + +interface FormState { + errors: FormErrors; + success: boolean; +} + +async function handleFormRequest(): Promise { + const errors: FormErrors = { name: "", phone: "", msg: "", code: "", captcha: "", form: "" }; + let success = false; + + if (Astro.request.method !== "POST") { + return { errors, success }; + } + + try { + const data = await Astro.request.formData(); + const rawCapToken = data.get("cap-token"); + const rawAction = data.get("action"); + const rawName = data.get("name"); + const rawPhone = data.get("phone"); + const rawMsg = data.get("msg"); + const rawCode = data.get("code"); + + const submittedFields = [rawCapToken, rawAction, rawName, rawPhone, rawMsg, rawCode]; + if (!submittedFields.every((field): field is string => typeof field === "string")) { + throw new Error("Invalid form submission."); + } + + const [capToken, action, name, phone, msg, code] = submittedFields; + + const capValidation = await CapServer.validateToken(capToken); + if (!capValidation.success) { + errors.captcha = "Invalid captcha token."; + return { errors, success }; + } + + if (action !== "send_otp" && action !== "send_msg") { + errors.form = "Invalid action."; + return { errors, success }; + } + + const result = action === "send_otp" + ? await sendOtp(phone) + : await sendMsg(name, phone, code, msg); + + if (!result.success) { + const target = result.field ?? "form"; + errors[target] = result.message; + return { errors, success }; + } + + if (action === "send_otp") { + errors.form = result.message ?? ""; + return { errors, success }; + } + + success = true; + return { errors, success }; + } catch (error) { + if (error instanceof Error) { + errors.form = error.message; + } else { + errors.form = "An unexpected error occurred."; + } + + return { errors, success }; + } +} + +const { errors, success } = await handleFormRequest(); +--- + + + + +

      Contact

      +{!success &&
      +
      +

      Use the below form to shoot me a quick text!

      + {errors.form &&

      {errors.form}

      } +
      + + + + + + + +
      ||

      Your message has been sent successfully!

      } diff --git a/src/lib/cap.ts b/src/lib/CapAdapter.ts similarity index 100% rename from src/lib/cap.ts rename to src/lib/CapAdapter.ts diff --git a/src/lib/Otp.ts b/src/lib/Otp.ts new file mode 100644 index 0000000..95969f4 --- /dev/null +++ b/src/lib/Otp.ts @@ -0,0 +1,152 @@ +import { authenticator } from "otplib"; +import { createHash } from "crypto"; + +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 = 60; +const VALID_PAST_OTP_STEPS: number = 5; +const VALID_FUTURE_OTP_STEPS: number = 1; +const OTP_NUM_DIGITS: number = 6; + +authenticator.options = { + step: OTP_STEP_IN_SEC, + window: [VALID_PAST_OTP_STEPS, VALID_FUTURE_OTP_STEPS], + digits: OTP_NUM_DIGITS, +}; + +function getUserSecret(phoneNumber: string, salt: string): string { + if (!phoneNumber || !salt) { + throw new Error( + "Phone number and salt are required to generate a user secret.", + ); + } + return createHash("sha256") + .update(phoneNumber + salt) + .digest("hex"); +} + +export function validatePhoneNumber(unsafePhoneNum: string) { + if (typeof unsafePhoneNum !== "string") { + return { success: false, message: "Invalid phone number." }; + } + + unsafePhoneNum = unsafePhoneNum.replace(/[^0-9]/g, "").trim(); + const cleanedNumber = unsafePhoneNum.startsWith("1") + ? unsafePhoneNum.substring(1) + : unsafePhoneNum; + + const isValidFormat = /^[2-7][0-8][0-9][2-9][0-9]{6}$/.test(cleanedNumber); + const isNotAllSameDigit = !/^(.)\1{9}$/.test(cleanedNumber); + const isNot911Number = !/^[0-9]{3}911[0-9]{4}$/.test(cleanedNumber); + const isNot555Number = !/^[0-9]{3}555[0-9]{4}$/.test(cleanedNumber); + const isNotPopSongNumber = !/^[0-9]{3}8675309$/.test(cleanedNumber); + + if ( + isValidFormat && + isNotAllSameDigit && + isNot911Number && + isNot555Number && + isNotPopSongNumber + ) { + return { success: true, validatedPhoneNumber: cleanedNumber }; + } + + return { success: false, validatedPhoneNumber: undefined }; +} + +export function generateOtp(phoneNumber: string, salt: string): string { + const userSecret = getUserSecret(phoneNumber, salt); + return authenticator.generate(userSecret); +} + +export function verifyOtp( + phoneNumber: string, + salt: string, + token: string, +): boolean { + const userSecret = getUserSecret(phoneNumber, salt); + return authenticator.verify({ token, secret: userSecret }); +} + +export function getOtpStep(): number { + const step = authenticator.options.step; + if (typeof step !== "number") { + return 0; + } + return step; +} + +export function isRateLimitedForMsgs(phoneNumber: string): boolean { + const submissionTimestampsArray = submissionTimestamps.get(phoneNumber); + if (!submissionTimestampsArray || submissionTimestampsArray.length === 0) { + return false; + } + + const now = Date.now(); + const recentSubmissions = submissionTimestampsArray.filter( + (timestamp: number) => now - timestamp < ONE_WEEK_IN_MS, + ); + + if (recentSubmissions.length !== submissionTimestampsArray.length) { + submissionTimestamps.set(phoneNumber, recentSubmissions); + } + + return recentSubmissions.length >= MAX_MESSAGES_PER_WEEK; +} + +export function recordMsgSubmission(phoneNumber: string) { + const now = Date.now(); + const existingSubmissions = submissionTimestamps.get(phoneNumber) || []; + + const recentSubmissions = existingSubmissions.filter( + (timestamp: number) => now - timestamp < ONE_WEEK_IN_MS, + ); + recentSubmissions.push(now); + + submissionTimestamps.set(phoneNumber, recentSubmissions); +} + +export function isRateLimitedForOtp(phoneNumber: string): boolean { + const requestTimestamps = otpRequestTimestamps.get(phoneNumber); + if (!requestTimestamps || requestTimestamps.length === 0) { + return false; + } + + const now = Date.now(); + const recentRequests = requestTimestamps.filter( + (timestamp: number) => now - timestamp < ONE_HOUR_IN_MS, + ); + + if (recentRequests.length !== requestTimestamps.length) { + otpRequestTimestamps.set(phoneNumber, recentRequests); + } + + return recentRequests.length >= MAX_OTP_REQUESTS_PER_HOUR; +} + +export function recordOtpRequest(phoneNumber: string) { + const now = Date.now(); + const existingRequests = otpRequestTimestamps.get(phoneNumber) || []; + + const recentRequests = existingRequests.filter( + (timestamp: number) => now - timestamp < ONE_HOUR_IN_MS, + ); + recentRequests.push(now); + + otpRequestTimestamps.set(phoneNumber, recentRequests); +} + +export default { + validatePhoneNumber, + generateOtp, + verifyOtp, + getOtpStep, + recordOtpRequest, + recordMsgSubmission, + isRateLimitedForOtp, + isRateLimitedForMsgs, +}; diff --git a/src/lib/SmsGatewayClient.ts b/src/lib/SmsGatewayClient.ts index e8a5de4..966b408 100644 --- a/src/lib/SmsGatewayClient.ts +++ b/src/lib/SmsGatewayClient.ts @@ -2,7 +2,6 @@ import Client from "android-sms-gateway"; import { ANDROID_SMS_GATEWAY_LOGIN, ANDROID_SMS_GATEWAY_PASSWORD, - ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, ANDROID_SMS_GATEWAY_URL, } from "astro:env/server"; import httpFetchClient from "@lib/HttpFetchClient"; @@ -19,9 +18,9 @@ class SmsClient { ); } - async sendSMS(message: string) { + async sendSMS(phoneNumber: string, message: string) { const bundle = { - phoneNumbers: [ANDROID_SMS_GATEWAY_RECIPIENT_PHONE], // hard-coded on purpose ;) + phoneNumbers: [phoneNumber], message: message, }; try { diff --git a/src/pages/cap/challenge.ts b/src/pages/cap/challenge.ts index 65a4105..07f1c63 100644 --- a/src/pages/cap/challenge.ts +++ b/src/pages/cap/challenge.ts @@ -1,8 +1,8 @@ import type { APIRoute } from "astro"; -import cap from "@lib/cap"; +import cap from "@lib/CapAdapter"; export const prerender = false; -export const POST: APIRoute = async ({ request }) => { +export const POST: APIRoute = async () => { try { return new Response(JSON.stringify(await cap.createChallenge()), { status: 200, diff --git a/src/pages/cap/redeem.ts b/src/pages/cap/redeem.ts index d66f189..d8c078a 100644 --- a/src/pages/cap/redeem.ts +++ b/src/pages/cap/redeem.ts @@ -1,5 +1,5 @@ import type { APIRoute } from "astro"; -import cap from "@lib/cap"; +import cap from "@lib/CapAdapter"; export const prerender = false; export const POST: APIRoute = async ({ request }) => { diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 3ecbe81..a20be24 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -1,132 +1,11 @@ --- import Layout from "@layouts/BaseLayout.astro"; -import SmsClient from "@lib/SmsGatewayClient.ts"; -import CapServer from "@lib/cap"; +import ContactForm from "@components/ContactForm.astro"; export const prerender = false; - -const errors = { name: "", phone: "", msg: "", form: "" }; -let success = false; -if (Astro.request.method === "POST") { - try { - const data = await Astro.request.formData(); - const name = data.get("name")?.toString(); - const capToken = data.get("cap-token")?.toString(); - const phone = data.get("phone")?.toString(); - const msg = data.get("msg")?.toString(); - - if (typeof capToken !== "string" || !(await CapServer.validateToken(capToken)).success) { - throw new Error("invalid cap token"); - } - - if (typeof name !== "string" || name.length < 1) { - errors.name += "Please enter a name. "; - } - if (typeof phone !== "string") { - errors.phone += "Phone is not valid. "; - } - if (typeof msg !== "string" || msg.length < 20) { - errors.msg += "Message must be at least 20 characters. "; - } - - const hasErrors = Object.values(errors).some(msg => msg) - if (!hasErrors) { - const smsClient = new SmsClient(); - const message = "Web message from " + name + " (" + phone + "):\n\n" + msg; - const result = await smsClient.sendSMS(message); - if (!result.success) { - errors.form += "Sending SMS failed; API returned error. " - } else { success = true; } - } - } catch (error) { - if (error instanceof Error) { - errors.form += error.message; - } - } -} --- - - Contact - -

      Contact

      - {!success &&
      -
      -

      Use the below form to shoot me a quick text!

      - {errors.form &&

      {errors.form}

      } -
      - - - - - -
      ||

      Your message has been sent successfully!

      } +