From 8e35387841b10c6df8e925d6a890e0cb2fe3dd81 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:49:06 -0800 Subject: [PATCH] Switch to Cap invisible widget, add form drafts to middleware, and improve OTP validation Use the Cap client widget in the contact UI with status icons and auto-solve, replacing the capwidget element. Normalize and tighten phone validation by splitting normalizePhone and isValidPhone in the Otp lib and use it in contact action validation. Replace loose text validation with a character-stripper helper. Also bump several dependencies and adjust middleware to save and restore form data for form actions. --- bun.lockb | Bin 215964 -> 227174 bytes package.json | 10 +- src/actions/contact.ts | 101 +++++------- src/lib/Otp.ts | 44 +++--- src/middleware.ts | 12 +- src/pages/cap/challenge.ts | 9 +- src/pages/contact.astro | 307 +++++++++++++++++++++---------------- 7 files changed, 261 insertions(+), 222 deletions(-) diff --git a/bun.lockb b/bun.lockb index 06bbc8400ccacbc9539c607fa9cf057051396080..2656e95f5c885dacca2d56136e574d3aba4e3571 100755 GIT binary patch delta 17802 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

l=8chFjLNcr-asE!`mkmnDy0XhN!Ks%tll25f} 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 delta 13674 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 TyV%B47ifr^v5fod67&8Ge>Bh2 diff --git a/package.json b/package.json index f218011..804ecd6 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,15 @@ "@astrojs/db": "^0.18.3", "@astrojs/node": "^9.5.2", "@astrojs/partytown": "^2.1.4", - "@astrojs/sitemap": "^3.6.0", + "@astrojs/sitemap": "^3.7.0", "@astrojs/ts-plugin": "^1.10.6", "@cap.js/server": "^4.0.5", - "@cap.js/widget": "^0.1.33", + "@cap.js/widget": "^0.1.34", "@nurodev/astro-bun": "^2.1.2", "@types/alpinejs": "^3.13.11", - "alpinejs": "^3.15.3", + "alpinejs": "^3.15.5", "android-sms-gateway": "^3.0.0", - "astro": "^5.16.6", + "astro": "^5.16.15", "astro-htmx": "^1.0.6", "htmx.org": "^2.0.8", "iconify-icon": "^3.0.2", @@ -35,7 +35,7 @@ "validator": "^13.15.26" }, "devDependencies": { - "@types/bun": "^1.3.5", + "@types/bun": "^1.3.6", "@types/validator": "^13.15.10", "prettier": "^3.8.1", "prettier-plugin-astro": "^0.14.1" diff --git a/src/actions/contact.ts b/src/actions/contact.ts index b59b9f0..1e5241a 100644 --- a/src/actions/contact.ts +++ b/src/actions/contact.ts @@ -10,20 +10,10 @@ import { ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, } from "astro:env/server"; -const isValidCaptcha: [(data: string) => any, { message: string }] = [ - async (value: string) => - typeof console.log(value) && - /^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(value) && - (await CapServer.validateToken(value)), - { - message: "Invalid captcha token.", - }, -]; - -const stripLow = (value: string) => validator.stripLow(value); - -const isMobilePhone: [(data: string) => any, { message: string }] = [ - (value: string) => validator.isMobilePhone(value, ["en-US", "en-CA"]), +const isValidMobilePhone: [(data: string) => any, { message: string }] = [ + (value: string) => + validator.isMobilePhone(value, ["en-US", "en-CA"]) && + Otp.isValidPhone(value), { message: "Invalid phone number" }, ]; @@ -38,45 +28,30 @@ const noExcessiveRepetitions: [(data: string) => any, { message: string }] = [ { message: "No excessive repetitions!" }, ]; -const acceptableText: [(data: string) => any, { message: string }] = [ - (value: string) => - /^[\p{Letter}\p{Mark}\p{General_Category=Decimal_Number}\p{General_Category=Punctuation}\p{General_Category=Space_Separator}\p{General_Category=Symbol}\p{RGI_Emoji}]*$/v.test( - value, - ), - { - message: - "Only letters, numbers, punctuation, spaces, symbols, and emojis are allowed.", - }, -]; +const stripDisallowedCharacters = (value: string) => + value + .match( + /(?:[\p{Letter}\p{Mark}\p{General_Category=Decimal_Number}\p{General_Category=Punctuation}\p{General_Category=Space_Separator}\p{General_Category=Symbol}]|\p{RGI_Emoji})/gv, + ) + ?.join("") ?? ""; -const captcha_input = z - .string() - .trim() - .nonempty() - .refine(...isValidCaptcha); +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(stripLow) - .refine(...acceptableText), + name: z.string().trim().min(5).max(32).transform(stripDisallowedCharacters), phone: z .string() .trim() - .refine(...isMobilePhone), + .refine(...isValidMobilePhone), msg: z .string() .trim() .min(25) .max(512) - .transform(stripLow) + .transform(stripDisallowedCharacters) .refine(...noYelling) - .refine(...noExcessiveRepetitions) - .refine(...acceptableText), + .refine(...noExcessiveRepetitions), captcha: captcha_input, }); @@ -95,49 +70,41 @@ const submitActionDefinition = { input: formAction, handler: async (input: any, context: ActionAPIContext) => { if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) { + console.log("Server variables are missing."); throw new ActionError({ code: "INTERNAL_SERVER_ERROR", message: "Server variables are missing.", }); } + if ( + !( + /^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(input.captcha) && + (await CapServer.validateToken(input.captcha)) + ) + ) { + console.log("Invalid Captcha Token"); + throw new ActionError({ + code: "BAD_REQUEST", + message: "Invalid Captcha Token", + }); + } + if (input.action === "send_otp") { const { name, phone, msg } = input; - if (!phone || !Otp.validatePhoneNumber(phone)) { - throw new ActionError({ - code: "BAD_REQUEST", - message: "Invalid phone number.", - }); - } - - if (Otp.isRateLimitedForOtp(phone)) { - throw new ActionError({ - code: "TOO_MANY_REQUESTS", - message: "Too many OTP requests. Please try again later.", - }); - } - - if (Otp.isRateLimitedForMsgs(phone)) { - throw new ActionError({ - code: "TOO_MANY_REQUESTS", - message: "Too many message requests. Please try again later.", - }); - } const otp = Otp.generateOtp(phone, OTP_SUPER_SECRET_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} minutes${ remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "." }`; - const result = await api.sendSMS(phone, message); + const result = await new SmsClient().sendSMS(phone, message); + console.log(JSON.stringify(result)); if (result.success) { - Otp.recordOtpRequest(phone); - context.session?.set("phone", phone); context.session?.set("name", name); context.session?.set("msg", msg); @@ -146,6 +113,9 @@ const submitActionDefinition = { nextAction: "send_msg", }; } else { + console.log( + "Verification code failed to send. Please try again later.", + ); throw new ActionError({ code: "SERVICE_UNAVAILABLE", message: "Verification code failed to send. Please try again later.", @@ -158,6 +128,7 @@ const submitActionDefinition = { const msg = await context.session?.get("msg"); if (!name || !otp || !msg || !phone) { + console.log("Missing required fields."); throw new ActionError({ code: "BAD_REQUEST", message: "Missing required fields.", @@ -166,6 +137,7 @@ const submitActionDefinition = { const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp); if (!isVerified) { + console.log("Invalid or expired verification code."); throw new ActionError({ code: "BAD_REQUEST", message: "Invalid or expired verification code.", @@ -192,6 +164,7 @@ const submitActionDefinition = { }; } + console.log("Message failed to send."); throw new ActionError({ code: "SERVICE_UNAVAILABLE", message: "Message failed to send.", diff --git a/src/lib/Otp.ts b/src/lib/Otp.ts index a6f3ba5..347a02d 100644 --- a/src/lib/Otp.ts +++ b/src/lib/Otp.ts @@ -29,33 +29,36 @@ function getUserSecret(phoneNumber: string, salt: string): string { .digest("hex"); } -export function validatePhoneNumber(unsafePhoneNum: string) { - if (typeof unsafePhoneNum !== "string") { - return { success: false, message: "Invalid phone number." }; +export function normalizePhone(phone: string) { + const result = phone.replace(/[^\d]/g, "").trim().startsWith("1") + ? phone.substring(1) + : phone; + + if (result.length !== 10) { + throw new Error("Invalid phone number."); } - unsafePhoneNum = unsafePhoneNum.replace(/[^0-9]/g, "").trim(); - const cleanedNumber = unsafePhoneNum.startsWith("1") - ? unsafePhoneNum.substring(1) - : unsafePhoneNum; + return result; +} - 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); +export function isValidPhone(phone: string): boolean { + phone = normalizePhone(phone); + 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); + const isNotAllSameDigit = !/^(.)\1{6}$/.test(exchange + station); + const isNot911Number = prefix !== "911" && exchange !== "911"; + const isNot555Number = prefix !== "555" && exchange !== "555"; + const isNotPopSongNumber = exchange !== "867" && station !== "5309"; - if ( - isValidFormat && + return ( + isValidNANPFormat && isNotAllSameDigit && isNot911Number && isNot555Number && isNotPopSongNumber - ) { - return { success: true, validatedPhoneNumber: cleanedNumber }; - } - - return { success: false, validatedPhoneNumber: undefined }; + ); } export function generateOtp(phoneNumber: string, salt: string): string { @@ -141,7 +144,8 @@ export function recordOtpRequest(phoneNumber: string) { } export default { - validatePhoneNumber, + normalizePhone, + isValidPhone, generateOtp, verifyOtp, getOtpStep, diff --git a/src/middleware.ts b/src/middleware.ts index 106aec8..a97291a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,5 @@ import { defineMiddleware } from "astro:middleware"; import { getActionContext } from "astro:actions"; -import { randomUUID } from "node:crypto"; export const onRequest = defineMiddleware(async (context, next) => { if (context.isPrerendered) return next(); @@ -19,6 +18,7 @@ export const onRequest = defineMiddleware(async (context, next) => { } if (action?.calledFrom === "form") { + const formData = await context.request.clone().formData(); const actionResult = await action.handler(); context.session?.set( @@ -30,6 +30,15 @@ export const onRequest = defineMiddleware(async (context, next) => { ); if (actionResult.error) { + const draft = { + action: formData.get("action")?.toString() ?? "", + name: formData.get("name")?.toString() ?? "", + phone: formData.get("phone")?.toString() ?? "", + msg: formData.get("msg")?.toString() ?? "", + }; + + context.session?.set("contactFormDraft", draft); + const referer = context.request.headers.get("Referer"); if (!referer) { throw new Error( @@ -39,6 +48,7 @@ export const onRequest = defineMiddleware(async (context, next) => { return context.redirect(referer); } + context.session?.delete("contactFormDraft"); return context.redirect(context.originPathname); } diff --git a/src/pages/cap/challenge.ts b/src/pages/cap/challenge.ts index 07f1c63..35151b2 100644 --- a/src/pages/cap/challenge.ts +++ b/src/pages/cap/challenge.ts @@ -4,9 +4,12 @@ export const prerender = false; export const POST: APIRoute = async () => { try { - return new Response(JSON.stringify(await cap.createChallenge()), { - status: 200, - }); + return new Response( + JSON.stringify(await cap.createChallenge({ challengeDifficulty: 4 })), + { + status: 200, + }, + ); } catch { return new Response(JSON.stringify({ success: false }), { status: 400 }); } diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 93092c3..fdbbd47 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -4,88 +4,103 @@ import { actions, isInputError } from "astro:actions"; export const prerender = false; const result = Astro.getActionResult(actions.contact.submitForm); +// FIX (might be fixed with below change): if user types in invalid otp code, it returns an error +// and then nextAction is set to "send_otp". It needs to be set +// to "send_msg" if the error is caused by invalid otp code +// +// ALSO: change it maybe so user can always fill out all fields +// in one go, including otp code (have verify number swap with code field when sent) +// text me button should be disabled if otp code is invalid or missing const nextAction = result?.data?.nextAction || "send_otp"; const error = isInputError(result?.error) ? result.error.fields : {}; + +const formDraft = (await Astro.session?.get("contactFormDraft")) ?? undefined; +if (formDraft && Object.keys(formDraft).length) { + Astro.session?.delete("contactFormDraft"); +} + +const pickValue = (key: string) => + typeof formDraft?.[key] === "string" ? formDraft[key] : undefined; + +const nameValue = pickValue("name"); +const phoneValue = pickValue("phone"); +const msgValue = pickValue("msg"); --- Home -

- Contact -

+

Contact

{ (nextAction != "complete" && (
-
+ -
- +