diff --git a/astro.config.mjs b/astro.config.mjs index 63fff73..5103b88 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -19,6 +19,11 @@ export default defineConfig({ security: { checkOrigin: true, }, + session: { + driver: "lru-cache", + ttl: 3600, + maxEntries: 1000, + }, server: { host: true, port: 4321, diff --git a/src/components/ContactForm.astro b/src/components/ContactForm.astro index 3eea6bb..580ad44 100644 --- a/src/components/ContactForm.astro +++ b/src/components/ContactForm.astro @@ -1,229 +1,52 @@ --- -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"; +import { POST} from "@pages/endpoints/contact"; +import type { ContactFormErrors, ContactFormState, ContactFormResult } from "../types/ContactForm"; +type Props = Record; -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: "" }; +async function handleFormRequest(): Promise { + const errors: ContactFormErrors = { + name: "", + phone: "", + msg: "", + code: "", + captcha: "", + form: "", + }; let success = false; - - if (Astro.request.method !== "POST") { - return { errors, success }; - } + let action = "send_otp"; 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."); + let response: ContactFormResult<{ nextAction: string }> = + await (await POST(Astro))?.json(); + + if (!response) { + errors.form = "Invalid response."; + return { errors, success, action }; } + + if (!response.success) { + errors.form = response.message || "An unexpected error occurred."; + return { errors, success, action }; + } + + action = response.data.nextAction; - 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 }; + return { errors, success, action }; } catch (error) { if (error instanceof Error) { - errors.form = error.message; + errors.form = "An unexpected error occurred: " + error.message; } else { errors.form = "An unexpected error occurred."; } - return { errors, success }; + return { errors, success, action }; } } -const { errors, success } = await handleFormRequest(); +Astro.session?.set('init', true); // Make sure session cookie is set early, else error (better fix: disable html streaming maybe?) + +const { errors, success, action } = (Astro.request.method === "POST")? await handleFormRequest() : { errors: {}, success: false, action: "send_otp" }; + ---