diff --git a/.prettierrc.mjs b/.prettierrc.mjs new file mode 100644 index 0000000..7e61930 --- /dev/null +++ b/.prettierrc.mjs @@ -0,0 +1,12 @@ +/** @type {import("prettier").Config} */ +export default { + plugins: ["prettier-plugin-astro"], + overrides: [ + { + files: "*.astro", + options: { + parser: "astro", + }, + }, + ], +}; diff --git a/bun.lockb b/bun.lockb index 41a9d0f..06bbc84 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 95fa7a8..f218011 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,13 @@ "htmx.org": "^2.0.8", "iconify-icon": "^3.0.2", "otplib": "^12.0.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "validator": "^13.15.26" }, "devDependencies": { - "@types/bun": "^1.3.5" + "@types/bun": "^1.3.5", + "@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 new file mode 100644 index 0000000..b59b9f0 --- /dev/null +++ b/src/actions/contact.ts @@ -0,0 +1,206 @@ +import { defineAction, ActionError } from "astro:actions"; +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 CapServer from "@lib/CapAdapter"; +import { + OTP_SUPER_SECRET_SALT, + 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"]), + { message: "Invalid phone number" }, +]; + +const noYelling: [(data: string) => any, { message: string }] = [ + (value: string) => + (value.match(/\p{Uppercase_Letter}/gv) || []).length / value.length < 0.1, + { message: "No yelling!" }, +]; + +const noExcessiveRepetitions: [(data: string) => any, { message: string }] = [ + (value: string) => !/(.)\1{2,}/.test(value), + { 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 captcha_input = z + .string() + .trim() + .nonempty() + .refine(...isValidCaptcha); + +const sendOtpAction = z.object({ + action: z.literal("send_otp"), + name: z + .string() + .trim() + .min(5) + .max(32) + .transform(stripLow) + .refine(...acceptableText), + phone: z + .string() + .trim() + .refine(...isMobilePhone), + msg: z + .string() + .trim() + .min(25) + .max(512) + .transform(stripLow) + .refine(...noYelling) + .refine(...noExcessiveRepetitions) + .refine(...acceptableText), + captcha: captcha_input, +}); + +const sendMsgAction = z.object({ + action: z.literal("send_msg"), + otp: z.string().trim().length(6), + captcha: captcha_input, +}); + +const formAction = z.discriminatedUnion("action", [ + sendOtpAction, + sendMsgAction, +]); + +const submitActionDefinition = { + input: formAction, + handler: async (input: any, context: ActionAPIContext) => { + if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) { + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: "Server variables are missing.", + }); + } + + 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); + + if (result.success) { + Otp.recordOtpRequest(phone); + + context.session?.set("phone", phone); + context.session?.set("name", name); + context.session?.set("msg", msg); + + return { + nextAction: "send_msg", + }; + } else { + throw new ActionError({ + code: "SERVICE_UNAVAILABLE", + message: "Verification code failed to send. Please 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 msg = await context.session?.get("msg"); + + if (!name || !otp || !msg || !phone) { + throw new ActionError({ + code: "BAD_REQUEST", + message: "Missing required fields.", + }); + } + + const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp); + if (!isVerified) { + throw new ActionError({ + code: "BAD_REQUEST", + message: "Invalid or expired verification code.", + }); + } + + const message = `Web message from ${name} ( ${phone} ):\n\n${msg}`; + + const smsClient = new SmsClient(); + const result = await smsClient.sendSMS( + ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, + message, + ); + + if (result.success) { + Otp.recordMsgSubmission(phone); + + context.session?.delete("phone"); + context.session?.delete("name"); + context.session?.delete("msg"); + + return { + nextAction: "complete", + }; + } + + throw new ActionError({ + code: "SERVICE_UNAVAILABLE", + message: "Message failed to send.", + }); + } + }, +}; + +export const contact = { + submitForm: defineAction({ ...submitActionDefinition, accept: "form" }), + submitJson: defineAction({ ...submitActionDefinition, accept: "json" }), +}; diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 0000000..8e56698 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,5 @@ +import { contact } from "./contact.ts"; + +export const server = { + contact, +}; diff --git a/src/components/ContactForm.astro b/src/components/ContactForm.astro deleted file mode 100644 index 2cdc617..0000000 --- a/src/components/ContactForm.astro +++ /dev/null @@ -1,160 +0,0 @@ ---- -import { POST, generateInitialState } from "@pages/endpoints/contact"; -import * as ContactFormTypes from "../types/ContactForm"; - -async function handlePost(): Promise { - try { - let response = - await (await POST(Astro))?.json(); - - if (!response) { - return generateInitialState("Invalid response."); - } - - return response; - } catch (error) { - let message = "An unexpected error occurred."; - if (error instanceof Error) { - message = "An unexpected error occurred: " + error.message; - } - - return generateInitialState(message); - } -} - -// CANNOT USE SESSION INSIDE AN ASTRO COMPONENT! MUST REVALIDATE FORM FIELDS OR CONVERT TO REGULAR PAGE (preferable as there will never be more than one contact form) - -const state = (Astro.request.method === "POST")? await handlePost() : generateInitialState(); - ---- - - - - -

Contact

-{state.state !== "complete" &&
-
-

Use the below form to shoot me a quick text!

- {state.error &&

{state.error}

} -
-
- - - -
- -
- -
- - -
||

Your message has been sent successfully!

} diff --git a/src/lib/Otp.ts b/src/lib/Otp.ts index 95969f4..a6f3ba5 100644 --- a/src/lib/Otp.ts +++ b/src/lib/Otp.ts @@ -7,8 +7,8 @@ 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 OTP_STEP_IN_SEC: number = 300; +const VALID_PAST_OTP_STEPS: number = 1; const VALID_FUTURE_OTP_STEPS: number = 1; const OTP_NUM_DIGITS: number = 6; diff --git a/src/lib/contact.ts b/src/lib/contact.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 3e753c4..93092c3 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -1,55 +1,48 @@ --- import Layout from "@layouts/BaseLayout.astro"; -import { POST, generateInitialState } from "@pages/endpoints/contact"; -import * as ContactFormTypes from "../types/ContactForm"; +import { actions, isInputError } from "astro:actions"; export const prerender = false; -async function handlePost(): Promise { - try { - let response = - await (await POST(Astro))?.json(); - - if (!response) { - return generateInitialState("Invalid response."); - } - - return response; - } catch (error) { - let message = "An unexpected error occurred."; - if (error instanceof Error) { - message = "An unexpected error occurred: " + error.message; - } - - return generateInitialState(message); - } -} - -const state = (Astro.request.method === "POST")? await handlePost() : generateInitialState(); +const result = Astro.getActionResult(actions.contact.submitForm); +const nextAction = result?.data?.nextAction || "send_otp"; +const error = isInputError(result?.error) ? result.error.fields : {}; --- + -