Progress on contact form refactor from JS into Typescript

This commit is contained in:
badblocks 2026-01-13 22:24:46 -08:00
parent 72e57fb7ff
commit f5eac7145c
No known key found for this signature in database
7 changed files with 451 additions and 239 deletions

View file

@ -19,6 +19,11 @@ export default defineConfig({
security: { security: {
checkOrigin: true, checkOrigin: true,
}, },
session: {
driver: "lru-cache",
ttl: 3600,
maxEntries: 1000,
},
server: { server: {
host: true, host: true,
port: 4321, port: 4321,

View file

@ -1,229 +1,52 @@
--- ---
import SmsClient from "@lib/SmsGatewayClient.ts"; import { POST} from "@pages/endpoints/contact";
import CapServer from "@lib/CapAdapter"; import type { ContactFormErrors, ContactFormState, ContactFormResult } from "../types/ContactForm";
import Otp, { verifyOtp } from "@lib/Otp.ts" type Props = Record<string, never>;
import { OTP_SUPER_SECRET_SALT, ANDROID_SMS_GATEWAY_RECIPIENT_PHONE } from "astro:env/server";
type FormErrors = { name: string; phone: string; msg: string; code: string; captcha: string; form: string }; async function handleFormRequest(): Promise<ContactFormState> {
const errors: ContactFormErrors = {
type ValidationFailure = { success: false; message: string; field?: keyof FormErrors }; name: "",
type ValidationSuccess<T = void> = { success: true; data: T; message?: string }; phone: "",
type ValidationResult<T = void> = ValidationSuccess<T> | ValidationFailure; msg: "",
code: "",
const OTP_SALT = OTP_SUPER_SECRET_SALT; captcha: "",
if (!OTP_SALT) { form: "",
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<ValidationResult> {
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<ValidationResult> {
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<FormState> {
const errors: FormErrors = { name: "", phone: "", msg: "", code: "", captcha: "", form: "" };
let success = false; let success = false;
let action = "send_otp";
if (Astro.request.method !== "POST") {
return { errors, success };
}
try { try {
const data = await Astro.request.formData(); let response: ContactFormResult<{ nextAction: string }> =
const rawCapToken = data.get("cap-token"); await (await POST(Astro))?.json();
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 (!response) {
if (!submittedFields.every((field): field is string => typeof field === "string")) { errors.form = "Invalid response.";
throw new Error("Invalid form submission."); return { errors, success, action };
} }
const [capToken, action, name, phone, msg, code] = submittedFields; if (!response.success) {
errors.form = response.message || "An unexpected error occurred.";
const capValidation = await CapServer.validateToken(capToken); return { errors, success, action };
if (!capValidation.success) {
errors.captcha = "Invalid captcha token.";
return { errors, success };
} }
if (action !== "send_otp" && action !== "send_msg") { action = response.data.nextAction;
errors.form = "Invalid action.";
return { errors, success };
}
const result = action === "send_otp" return { errors, success, action };
? 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 };
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
errors.form = error.message; errors.form = "An unexpected error occurred: " + error.message;
} else { } else {
errors.form = "An unexpected error occurred."; 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" };
--- ---
<script> <script>
// @ts-nocheck // @ts-nocheck
@ -265,7 +88,7 @@ const { errors, success } = await handleFormRequest();
--cap-spinner-background-color: var(--input-bg); --cap-spinner-background-color: var(--input-bg);
--cap-spinner-thickness: 2px; --cap-spinner-thickness: 2px;
--cap-credits-display: inline; --cap-credits-display: inline;
margin: 0 0 0 0; margin: 0;
display: block; display: block;
} }
cap-widget::part(attribution) { cap-widget::part(attribution) {
@ -283,6 +106,9 @@ const { errors, success } = await handleFormRequest();
"captcha captcha verify verify" "captcha captcha verify verify"
"code code submit submit"; "code code submit submit";
} }
form fieldset {
display: contents;
}
#captcha-credits { #captcha-credits {
font-size: x-small; font-size: x-small;
} }
@ -302,25 +128,26 @@ const { errors, success } = await handleFormRequest();
grid-area: captcha; grid-area: captcha;
margin-bottom: 0; margin-bottom: 0;
} }
button[name="action"] { button {
height: 39px; height: 39px;
margin: 29px 0 10px 0; margin: 29px 0 10px 0;
padding: 8px 12px; padding: 8px 12px;
} }
button[name="action"]#send_otp { button#send_otp {
grid-area: verify; grid-area: verify;
} }
button[name="action"]#send_msg { button#send_msg {
grid-area: submit; grid-area: submit;
} }
</style> </style>
<h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2> <h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2>
{!success && <form method="post" x-data="{}"> {!success && action != "success" && <form method="post" x-data="{}">
<div> <div>
<p>Use the below form to shoot me a quick text!</p> <p>Use the below form to shoot me a quick text!</p>
{errors.form && <p>{errors.form}</p>} {errors.form && <p>{errors.form}</p>}
</div> </div>
<fieldset id="send_otp" disabled={action !== "send_otp"}>
<label for="name"> <label for="name">
Name Name
<input type="text" id="name" name="name" value="Bad Blocks" /> <input type="text" id="name" name="name" value="Bad Blocks" />
@ -328,7 +155,7 @@ const { errors, success } = await handleFormRequest();
</label> </label>
<label for="phone"> <label for="phone">
Phone Phone
<input type="text" id="phone" name="phone" value="555-555-5555" /> <input type="text" id="phone" name="phone" value="206-745-2154" />
{errors.phone && <p>{errors.phone}</p>} {errors.phone && <p>{errors.phone}</p>}
</label> </label>
<label for="msg"> <label for="msg">
@ -336,16 +163,19 @@ const { errors, success } = await handleFormRequest();
<textarea id="msg" name="msg">I think badblocks rocks! A quick brown fox jumped over the lazy dog!</textarea> <textarea id="msg" name="msg">I think badblocks rocks! A quick brown fox jumped over the lazy dog!</textarea>
{errors.msg && <p>{errors.msg}</p>} {errors.msg && <p>{errors.msg}</p>}
</label> </label>
<label for="captcha"> </fieldset>
Captcha <span id="captcha-credits"></span> <button id="send_otp" name="action" value="send_otp" type="submit" disabled={action !== "send_otp"}>Verify Your Number!</button>
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget> <fieldset id="send_msg" disabled={action !== "send_msg"}>
{errors.captcha && <p>{errors.captcha}</p>}
</label>
<label for="code"> <label for="code">
Code Code
<input type="text" id="code" name="code" placeholder="123456" /> <input type="text" id="code" name="code" placeholder="123456" />
{errors.code && <p>{errors.code}</p>} {errors.code && <p>{errors.code}</p>}
</label> </label>
<button id="send_otp" name="action" value="send_otp" type="submit">Verify Your Number!</button> </fieldset>
<button id="send_msg" name="action" value="send_msg" type="submit">Send It!</button> <button id="send_msg" name="action" value="send_msg" type="submit" disabled={action !== "send_msg"}>Text Me!</button>
<label for="captcha">
Captcha <span id="captcha-credits"></span>
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
{errors.captcha && <p>{errors.captcha}</p>}
</label>
</form> || <p>Your message has been sent successfully!</p>} </form> || <p>Your message has been sent successfully!</p>}

View file

@ -31,7 +31,10 @@ class SmsClient {
state: msg_state.state, state: msg_state.state,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error }; if (error instanceof Error) {
return { success: false, message: error.message };
}
return { success: false, message: "Unknown sendSMS error." };
} }
} }
@ -44,7 +47,7 @@ class SmsClient {
state: msg_state.state, state: msg_state.state,
}; };
} catch (error) { } catch (error) {
return { success: false, id: id, error: error }; return { success: false, id: id, message: error };
} }
} }
} }

0
src/lib/contact.ts Normal file
View file

View file

@ -0,0 +1,330 @@
import type { APIContext, APIRoute, AstroGlobal } from "astro";
import SmsClient from "@lib/SmsGatewayClient.ts";
import Otp, { verifyOtp } from "@lib/Otp.ts";
import CapServer from "@lib/CapAdapter";
import * as ContactForm from "../../types/ContactForm";
import {
OTP_SUPER_SECRET_SALT,
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
} from "astro:env/server";
import type { defaultSettings } from "astro/runtime/client/dev-toolbar/settings.js";
export const prerender = false;
const OTP_SALT = OTP_SUPER_SECRET_SALT;
if (!OTP_SALT) {
throw new Error("OTP secret salt configuration is missing.");
}
async function sendOtp({
phone,
}: ContactFormOtpPayload): Promise<SendSMSResult> {
const otp = Otp.generateOtp(phone, 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(phone, message);
if (result.success) {
Otp.recordOtpRequest(phone);
return {
success: true,
expiresInSeconds: stepSeconds,
};
} else {
return {
success: false,
errors: { form: "Verification code failed to send." },
};
}
}
async function sendMsg({
name,
phone,
code,
msg,
}: ContactFormMsgPayload): Promise<SendSMSResult> {
const message = `Web message from ${name} ( ${phone} ):\n\n"${msg}"`;
const isVerified = verifyOtp(phone, OTP_SALT, code);
if (!isVerified) {
return {
success: false,
errors: { code: "Invalid or expired verification code." },
};
}
const smsClient = new SmsClient();
const result = await smsClient.sendSMS(
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
message,
);
if (result.success) {
Otp.recordMsgSubmission(phone);
return {
success: true,
};
}
return {
success: false,
errors: { form: "Message failed to send." },
};
}
export const ALL: APIRoute = () => {
return new Response(
JSON.stringify({
success: false,
message: "Invalid HTTP method.",
field: "form",
}),
{ status: 400 },
);
};
function validateFields<K extends ContactForm.FieldKey>(
unsafe: ContactForm.Fields<K>,
): ContactForm.Fields<K> {
const fields: Partial<ContactForm.Fields<K>> = {};
const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/;
const sixDigitsOnlyRegex = /^[0-9]{6}$/;
const excessiveRepeatedCharactersRegex = /([a-zA-Z])\1{4,}/;
for (const field of Object.keys(unsafe) as K[]) {
let { value, error } = unsafe[field];
if (!value) {
fields[field] = {
hasError: true,
error: "Field is required.",
};
continue;
}
switch (field) {
case "phone": {
const result = Otp.validatePhoneNumber(value);
if (
!result.success ||
typeof result.validatedPhoneNumber !== "string"
) {
error = "Invalid phone number.";
break;
}
if (Otp.isRateLimitedForOtp(value)) {
error = "Too many OTP requests. Please try again later.";
break;
}
if (Otp.isRateLimitedForMsgs(value)) {
error = "Too many messages. Please try again later.";
break;
}
value = result.validatedPhoneNumber;
break;
}
case "name": {
if (!printableAsciiRegex.test(value)) {
error = "Name contains non-ASCII or non-printable characters.";
break;
}
if (value.length < 2 || value.length > 25) {
error = "Name must be between 2 and 25 characters.";
break;
}
break;
}
case "msg": {
if (!printableAsciiRegex.test(value)) {
error = "Message contains non-ASCII or non-printable characters.";
break;
}
if (value.length > 500) {
error = "Message cannot be longer than 500 characters.";
break;
}
if (value.length < 20) {
error = "Message is too short.";
break;
}
if (excessiveRepeatedCharactersRegex.test(value)) {
error = "Message contains excessive repeated characters.";
break;
}
const uppercaseRatio =
(value.match(/[A-Z]/g) || []).length / value.length;
if (uppercaseRatio > 0.25) {
error = "Message contains excessive uppercase text.";
break;
}
break;
}
case "code": {
if (!sixDigitsOnlyRegex.test(value)) {
error = "OTP code invalid.";
break;
}
break;
}
}
if (error) {
fields[field] = { hasError: true, error };
} else {
fields[field] = { hasError: false, value };
}
}
return fields as ContactForm.Fields<K>;
}
const isValidState = (value: unknown): value is ContactForm.State => {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Partial<ContactForm.State>;
return (
typeof candidate.state === "string" &&
(typeof candidate.fields === "object" ||
typeof candidate.fields === "undefined") &&
(typeof candidate.error === "string" ||
typeof candidate.error === "undefined") &&
typeof candidate.hasError === "boolean"
);
};
export const POST: APIRoute = async (Astro) => {
const respondWithState = (state: ContactForm.State) =>
new Response(JSON.stringify(state), {
status: state.hasError ? 400 : 200,
});
try {
const initialState = await processRequestIntoState(Astro);
if (initialState.hasError) {
return respondWithState(initialState);
}
const validatedState = await validateState(initialState);
if (validatedState.hasError) {
return respondWithState(validatedState);
}
const finalState = await runStateAction(validatedState);
return respondWithState(finalState);
} catch (caught) {
if (isValidState(caught)) {
return respondWithState(caught);
}
const message =
caught instanceof Error
? caught.message
: String(caught ?? "Unexpected error");
return respondWithState({
state: "initial",
fields: {},
hasError: true,
error: message,
});
}
};
export async function processRequestIntoState(
Astro: APIContext,
): Promise<ContactForm.State> {
const fields: Partial<ContactForm.Fields<ContactForm.FieldKey>> = {};
try {
const { request, session } = Astro;
if (!request) {
throw "Request is undefined.";
}
if (!session) {
throw "Session is undefined.";
}
const contentType = request.headers.get("Content-Type");
if (
contentType !== "application/json" &&
contentType !== "application/x-www-form-urlencoded"
) {
throw "Invalid Content-Type.";
}
const data =
contentType === "application/json"
? await request.json()
: await request.formData();
if (!data) {
throw "Data is undefined.";
}
const action = await data.get("action")?.toString();
if (!action) {
throw "Invalid action";
}
fields.name = {
hasError: false,
value:
action === "send_msg"
? session.get("name")?.toString()
: await data.get("name")?.toString(),
};
fields.phone = {
hasError: false,
value:
action === "send_msg"
? session.get("phone")?.toString()
: await data.get("phone")?.toString(),
};
fields.msg = {
hasError: false,
value:
action === "send_msg"
? session.get("msg")?.toString()
: await data.get("msg")?.toString(),
};
fields.captcha = {
hasError: false,
value: data.get("cap-token")?.toString(),
};
fields.code = { hasError: false, value: data.get("code")?.toString() };
return {
state: action,
fields,
hasError: false,
};
} catch (error) {
return {
state: "initial",
fields,
hasError: true,
error: error instanceof Error ? error.message : "Unknown error.",
};
}
}
export async function validateState(
state: ContactForm.State,
): Promise<ContactForm.State> {
state.fields = validateFields(state.fields);
// if state.fields has any errors, set hasError on state too and set a message
return state;
}
export async function runStateAction(
state: ContactForm.State,
): Promise<ContactForm.State> {
//Todo
return state;
}

42
src/types/ContactForm.ts Normal file
View file

@ -0,0 +1,42 @@
export type FieldKey = "name" | "phone" | "msg" | "code" | "captcha" | "form";
export type FieldValue = {
hasError: boolean;
value?: string;
error?: string;
};
export type Fields<K extends FieldKey> = { [P in K]-?: FieldValue };
export type BaseState<K extends FieldKey> = {
state: string;
fields: Fields<K>;
error?: string;
hasError: boolean;
};
export type InitialState = {
state: "initial";
} & BaseState<never>;
export type SendOtpState = {
state: "send_otp";
} & BaseState<"name" | "phone" | "msg" | "captcha">;
export type OtpSentState = {
state: "otp_sent";
} & BaseState<"name" | "phone" | "msg" | "captcha">;
export type SendMsgState = {
state: "send_msg";
} & BaseState<"name" | "phone" | "msg" | "code" | "captcha">;
export type CompleteState = {
state: "complete";
} & BaseState<never>;
export type State =
| InitialState
| SendOtpState
| OtpSentState
| SendMsgState
| CompleteState;

View file

@ -8,6 +8,8 @@
"@components/*": ["./src/components/*"], "@components/*": ["./src/components/*"],
"@layouts/*": ["./src/layouts/*"], "@layouts/*": ["./src/layouts/*"],
"@lib/*": ["./src/lib/*"], "@lib/*": ["./src/lib/*"],
"@pages/*": ["./src/pages/*"],
"@types/*": ["./src/types/*"],
}, },
"plugins": [ "plugins": [
{ {