diff --git a/bun.lockb b/bun.lockb index 06bbc84..2656e95 100755 Binary files a/bun.lockb and b/bun.lockb differ 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" && (
-
+ -
- +