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" && ( - + {(result?.error && ( - Unable to send {nextAction == "send_otp" ? "OTP" : "message"}. - Please correct any errors and try again. + {result?.error.message} + Please correct any errors and try again. )) || Use the below form to shoot me a quick text!} - - - Name - + Name + + {error.name && {error.name.join(",")}} + + + Captcha + + + + - {error.name && {error.name.join(",")}} - - - Phone - - {error.phone && {error.phone.join(",")}} - - - Msg - - {error.msg && {error.msg.join(",")}} - - + Loading... + + {error.captcha && {error.captcha.join(",")}} + + + Phone + + {error.phone && {error.phone.join(",")}} + + + Msg + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi augue eros, maximus nec ex sit amet, scelerisque interdum leo. Sed eu turpis sit amet dui congue efficitur. Duis eu laoreet risus, eget vestibulum lectus. + + + {error.msg && {error.msg.join(",")}} + - Verify Your Number! + Send Verification Code! - - - Code - - {error.otp && {error.otp.join(",")}} - - + + Verification Code + + {error.otp && {error.otp.join(",")}} + Text Me! - - Captcha - - {error.captcha && ( - {error.captcha.join(",")} - )} - - )) || Your message has been sent successfully! }
- Unable to send {nextAction == "send_otp" ? "OTP" : "message"}. - Please correct any errors and try again. + {result?.error.message} + Please correct any errors and try again.
Use the below form to shoot me a quick text!
{error.name.join(",")}
{error.phone.join(",")}
{error.msg.join(",")}
{error.captcha.join(",")}
{error.otp.join(",")}
Your message has been sent successfully!