Progress on contact form refactor from JS into Typescript
This commit is contained in:
parent
72e57fb7ff
commit
f5eac7145c
7 changed files with 451 additions and 239 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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
0
src/lib/contact.ts
Normal file
330
src/pages/endpoints/contact.ts
Normal file
330
src/pages/endpoints/contact.ts
Normal 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
42
src/types/ContactForm.ts
Normal 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;
|
||||||
|
|
@ -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": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue