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, normalizePhone } from "@lib/Otp.ts"; import { createCap } from "@lib/CapAdapter"; import { OTP_SUPER_SECRET_SALT, ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, } from "astro:env/server"; const MAX_OTP_VERIFY_ATTEMPTS = 5; const isValidMobilePhone: [(data: string) => any, { message: string }] = [ (value: string) => validator.isMobilePhone(value, ["en-US", "en-CA"]) && Otp.isValidPhone(value), { 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 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(); const sendOtpAction = z.object({ action: z.literal("send_otp"), name: z .string() .trim() .transform(stripDisallowedCharacters) .pipe(z.string().min(5).max(32)), phone: z .string() .trim() .refine(...isValidMobilePhone), msg: z .string() .trim() .transform(stripDisallowedCharacters) .pipe( z .string() .min(25) .max(512) .refine(...noYelling) .refine(...noExcessiveRepetitions), ), captcha: captcha_input, }); const sendMsgAction = z.object({ action: z.literal("send_msg"), otp: z.string().trim().length(6), captcha: captcha_input, }); const resetAction = z.object({ action: z.literal("reset"), captcha: captcha_input, }); const formAction = z.discriminatedUnion("action", [ sendOtpAction, sendMsgAction, resetAction, ]); const submitActionDefinition = { input: formAction, handler: async ( input: z.infer, context: ActionAPIContext, ) => { if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) { throw new ActionError({ code: "INTERNAL_SERVER_ERROR", message: "Server variables are missing.", }); } const cap = createCap(context.session ?? null); if (!( /^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(input.captcha) && (await cap.validateToken(input.captcha)) )) { throw new ActionError({ code: "BAD_REQUEST", message: "Invalid Captcha Token.", }); } if (input.action === "send_otp") { const { name, msg } = input; const phone = normalizePhone(input.phone); if ( Otp.isRateLimitedGlobally() || Otp.isRateLimitedForOtp(phone) || Otp.isRateLimitedForMsgs(phone) ) { throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many requests. 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 message = `${otp} is your verification code. This code is valid for ${stepMinutes} minutes${ remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "." }`; Otp.recordOtpRequest(phone); Otp.recordGlobalOtpSend(); const result = await new SmsClient().sendSMS(phone, message); if (result.success) { context.session?.set("phone", phone); context.session?.set("name", name); context.session?.set("msg", msg); return { nextAction: "send_msg", }; } else { console.error("OTP SMS send failed:", result.message); throw new ActionError({ code: "SERVICE_UNAVAILABLE", message: "Verification code failed to send. Try again later.", }); } } else if (input.action === "send_msg") { const { otp } = input; const name = await context.session?.get("name"); const raw_phone = await context.session?.get("phone"); const msg = await context.session?.get("msg"); if (!name || !otp || !msg || !raw_phone) { throw new ActionError({ code: "BAD_REQUEST", message: "Missing required fields.", }); } const phone = normalizePhone(raw_phone); if (Otp.isRateLimitedForOtp(phone) || Otp.isRateLimitedForMsgs(phone)) { throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many requests. Try again later.", }); } const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp); if (!isVerified) { const attempts = ((await context.session?.get("otpAttempts")) ?? 0) + 1; if (attempts >= MAX_OTP_VERIFY_ATTEMPTS) { context.session?.delete("phone"); context.session?.delete("name"); context.session?.delete("msg"); context.session?.delete("otpAttempts"); return { nextAction: "send_otp", error: "Too many incorrect codes. Please start over.", field: "otp", }; } context.session?.set("otpAttempts", attempts); return { nextAction: "send_msg", error: "Invalid or expired verification code.", field: "otp", }; } const message = `Web message from ${name} (${phone}):\n\n${msg}`; Otp.recordMsgSubmission(phone); const smsClient = new SmsClient(); const result = await smsClient.sendSMS( ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, message, ); if (result.success) { context.session?.delete("phone"); context.session?.delete("name"); context.session?.delete("msg"); context.session?.delete("otpAttempts"); return { nextAction: "complete", }; } console.error("Contact SMS send failed:", result.message); throw new ActionError({ code: "SERVICE_UNAVAILABLE", message: "Message failed to send.", }); } else if (input.action === "reset") { context.session?.delete("phone"); context.session?.delete("name"); context.session?.delete("msg"); context.session?.delete("otpAttempts"); return { nextAction: "send_otp", }; } }, }; export const contact = { submitForm: defineAction({ ...submitActionDefinition, accept: "form" }), submitJson: defineAction({ ...submitActionDefinition, accept: "json" }), };