Add contact actions and migrate contact form to Astro actions
Implement send_otp and send_msg handlers with server-side validation, captcha verification, OTP generation/verification, rate limiting, session-backed multi-step flow, and SMS gateway integration. Replace broken contact endpoint and types with Astro server actions; delete endpoints/contact.ts, types/ContactForm.ts and src/components/ContactForm.astro. Update pages/contact.astro to use astro:actions and improve captcha handling (listen for solve and set hidden input). Adjust OTP timing (step 60s → 300s, VALID_PAST_OTP_STEPS 5 → 1). Add validator, @types/validator, Prettier and prettier-plugin-astro; include .prettierrc.mjs in tsconfig include. Add Prettier config for Astro
This commit is contained in:
parent
608348a5a5
commit
f2f42a84b5
12 changed files with 360 additions and 746 deletions
12
.prettierrc.mjs
Normal file
12
.prettierrc.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/** @type {import("prettier").Config} */
|
||||||
|
export default {
|
||||||
|
plugins: ["prettier-plugin-astro"],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: "*.astro",
|
||||||
|
options: {
|
||||||
|
parser: "astro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -31,9 +31,13 @@
|
||||||
"htmx.org": "^2.0.8",
|
"htmx.org": "^2.0.8",
|
||||||
"iconify-icon": "^3.0.2",
|
"iconify-icon": "^3.0.2",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"validator": "^13.15.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
206
src/actions/contact.ts
Normal file
206
src/actions/contact.ts
Normal file
|
|
@ -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" }),
|
||||||
|
};
|
||||||
5
src/actions/index.ts
Normal file
5
src/actions/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { contact } from "./contact.ts";
|
||||||
|
|
||||||
|
export const server = {
|
||||||
|
contact,
|
||||||
|
};
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
---
|
|
||||||
import { POST, generateInitialState } from "@pages/endpoints/contact";
|
|
||||||
import * as ContactFormTypes from "../types/ContactForm";
|
|
||||||
|
|
||||||
async function handlePost(): Promise<ContactFormTypes.State> {
|
|
||||||
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();
|
|
||||||
|
|
||||||
---
|
|
||||||
<script>
|
|
||||||
// @ts-nocheck
|
|
||||||
import CapWidget from "@cap.js/widget";
|
|
||||||
import "iconify-icon";
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
const widget = document.querySelector('cap-widget');
|
|
||||||
const shadowRoot = widget?.shadowRoot;
|
|
||||||
const credits = shadowRoot?.querySelector('[part="attribution"]');
|
|
||||||
|
|
||||||
if (credits) {
|
|
||||||
const clone = credits.cloneNode(true);
|
|
||||||
const poweredByTextBefore = document.createTextNode("(by ");
|
|
||||||
const poweredByTextAfter = document.createTextNode(")");
|
|
||||||
document.querySelector('#captcha-credits')?.appendChild(poweredByTextBefore);
|
|
||||||
document.querySelector('#captcha-credits')?.appendChild(clone);
|
|
||||||
document.querySelector('#captcha-credits')?.appendChild(poweredByTextAfter);
|
|
||||||
widget?.style.setProperty('--cap-credits-display', 'none');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
cap-widget {
|
|
||||||
--cap-background: var(--bg-color);
|
|
||||||
--cap-border-color: rgba(255,255,255,0);
|
|
||||||
--cap-border-radius: 0;
|
|
||||||
--cap-widget-height: initial;
|
|
||||||
--cap-widget-width: 100%;
|
|
||||||
--cap-widget-padding: 0 0 11px 0;
|
|
||||||
--cap-gap: 3ch;
|
|
||||||
--cap-color: var(--text-color);
|
|
||||||
--cap-checkbox-size: 32px;
|
|
||||||
--cap-checkbox-border: 2px solid var(--border-color);
|
|
||||||
--cap-checkbox-border-radius: 4px;
|
|
||||||
--cap-checkbox-background: var(--input-bg);
|
|
||||||
--cap-checkbox-margin: 4px;
|
|
||||||
--cap-font: "Courier New", Courier, monospace;
|
|
||||||
--cap-spinner-color: var(--text-color);
|
|
||||||
--cap-spinner-background-color: var(--input-bg);
|
|
||||||
--cap-spinner-thickness: 2px;
|
|
||||||
--cap-credits-display: inline;
|
|
||||||
margin: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
cap-widget::part(attribution) {
|
|
||||||
display: var(--cap-credits-display);
|
|
||||||
}
|
|
||||||
form {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
grid-template-areas:
|
|
||||||
"header header header header "
|
|
||||||
"name name phone phone"
|
|
||||||
"msg msg msg msg"
|
|
||||||
"captcha captcha verify verify"
|
|
||||||
"otp otp submit submit";
|
|
||||||
}
|
|
||||||
form fieldset {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
#captcha-credits {
|
|
||||||
font-size: x-small;
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
grid-area: header;
|
|
||||||
}
|
|
||||||
label[for="name"] {
|
|
||||||
grid-area: name;
|
|
||||||
}
|
|
||||||
label[for="phone"] {
|
|
||||||
grid-area: phone;
|
|
||||||
}
|
|
||||||
label[for="msg"] {
|
|
||||||
grid-area: msg;
|
|
||||||
}
|
|
||||||
label[for="captcha"] {
|
|
||||||
grid-area: captcha;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
height: 39px;
|
|
||||||
margin: 29px 0 10px 0;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
button#send_otp {
|
|
||||||
grid-area: verify;
|
|
||||||
}
|
|
||||||
button#send_msg {
|
|
||||||
grid-area: submit;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2>
|
|
||||||
{state.state !== "complete" && <form method="post" x-data="{}">
|
|
||||||
<div>
|
|
||||||
<p>Use the below form to shoot me a quick text!</p>
|
|
||||||
{state.error && <p>{state.error}</p>}
|
|
||||||
</div>
|
|
||||||
<fieldset id="send_otp" disabled={state.state !== "initial"}>
|
|
||||||
<label for="name">
|
|
||||||
Name
|
|
||||||
<input type="text" id="name" name="name" value="Bad Blocks" />
|
|
||||||
{"name" in state.fields && state.fields.name.hasError && <p>{state.fields.name.error}</p>}
|
|
||||||
</label>
|
|
||||||
<label for="phone">
|
|
||||||
Phone
|
|
||||||
<input type="text" id="phone" name="phone" value="206-745-2154" />
|
|
||||||
{"phone" in state.fields && state.fields.phone.hasError && <p>{state.fields.phone.error}</p>}
|
|
||||||
</label>
|
|
||||||
<label for="msg">
|
|
||||||
Msg
|
|
||||||
<textarea id="msg" name="msg">I think badblocks rocks! A quick brown fox jumped over the lazy dog!</textarea>
|
|
||||||
{"msg" in state.fields && state.fields.msg.hasError && <p>{state.fields.msg.error}</p>}
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<button id="send_otp" name="action" value="send_otp" type="submit" disabled={state.state !== "initial"}>Verify Your Number!</button>
|
|
||||||
<fieldset id="send_msg" disabled={state.state !== "otp_sent"}>
|
|
||||||
<label for="otp">
|
|
||||||
Code
|
|
||||||
<input type="text" id="otp" name="otp" placeholder="123456" />
|
|
||||||
{"otp" in state.fields && state.fields.otp.hasError && <p>{state.fields.otp.error}</p>}
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<button id="send_msg" name="action" value="send_msg" type="submit" disabled={state.state !== "otp_sent"}>Text Me!</button>
|
|
||||||
<label for="captcha">
|
|
||||||
Captcha <span id="captcha-credits"></span>
|
|
||||||
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
|
|
||||||
{"captcha" in state.fields && state.fields.captcha.hasError && <p>{state.fields.captcha.error}</p>}
|
|
||||||
</label>
|
|
||||||
</form> || <p>Your message has been sent successfully!</p>}
|
|
||||||
|
|
@ -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 ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
|
||||||
const MAX_OTP_REQUESTS_PER_HOUR: number = 3;
|
const MAX_OTP_REQUESTS_PER_HOUR: number = 3;
|
||||||
const MAX_MESSAGES_PER_WEEK: number = 3;
|
const MAX_MESSAGES_PER_WEEK: number = 3;
|
||||||
const OTP_STEP_IN_SEC: number = 60;
|
const OTP_STEP_IN_SEC: number = 300;
|
||||||
const VALID_PAST_OTP_STEPS: number = 5;
|
const VALID_PAST_OTP_STEPS: number = 1;
|
||||||
const VALID_FUTURE_OTP_STEPS: number = 1;
|
const VALID_FUTURE_OTP_STEPS: number = 1;
|
||||||
const OTP_NUM_DIGITS: number = 6;
|
const OTP_NUM_DIGITS: number = 6;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,48 @@
|
||||||
---
|
---
|
||||||
import Layout from "@layouts/BaseLayout.astro";
|
import Layout from "@layouts/BaseLayout.astro";
|
||||||
import { POST, generateInitialState } from "@pages/endpoints/contact";
|
import { actions, isInputError } from "astro:actions";
|
||||||
import * as ContactFormTypes from "../types/ContactForm";
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
async function handlePost(): Promise<ContactFormTypes.State> {
|
const result = Astro.getActionResult(actions.contact.submitForm);
|
||||||
try {
|
const nextAction = result?.data?.nextAction || "send_otp";
|
||||||
let response =
|
const error = isInputError(result?.error) ? result.error.fields : {};
|
||||||
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();
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// @ts-nocheck
|
|
||||||
import CapWidget from "@cap.js/widget";
|
import CapWidget from "@cap.js/widget";
|
||||||
import "iconify-icon";
|
import "iconify-icon";
|
||||||
</script>
|
|
||||||
<script>
|
const widget = document.querySelector("cap-widget");
|
||||||
const widget = document.querySelector('cap-widget');
|
if (widget) {
|
||||||
const shadowRoot = widget?.shadowRoot;
|
const credits = widget?.shadowRoot?.querySelector('[part="attribution"]');
|
||||||
const credits = shadowRoot?.querySelector('[part="attribution"]');
|
|
||||||
|
if (credits) {
|
||||||
if (credits) {
|
const clone = credits.cloneNode(true);
|
||||||
const clone = credits.cloneNode(true);
|
const poweredByTextBefore = document.createTextNode("(by ");
|
||||||
const poweredByTextBefore = document.createTextNode("(by ");
|
const poweredByTextAfter = document.createTextNode(")");
|
||||||
const poweredByTextAfter = document.createTextNode(")");
|
document
|
||||||
document.querySelector('#captcha-credits')?.appendChild(poweredByTextBefore);
|
.querySelector("#captcha-credits")
|
||||||
document.querySelector('#captcha-credits')?.appendChild(clone);
|
?.appendChild(poweredByTextBefore);
|
||||||
document.querySelector('#captcha-credits')?.appendChild(poweredByTextAfter);
|
document.querySelector("#captcha-credits")?.appendChild(clone);
|
||||||
widget?.style.setProperty('--cap-credits-display', 'none');
|
document
|
||||||
|
.querySelector("#captcha-credits")
|
||||||
|
?.appendChild(poweredByTextAfter);
|
||||||
|
widget?.style.setProperty("--cap-credits-display", "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.addEventListener("solve", function (e) {
|
||||||
|
const token = e.detail.token;
|
||||||
|
const hiddenInput = document.querySelector("input[id='captcha']");
|
||||||
|
if (hiddenInput && "value" in hiddenInput) {
|
||||||
|
hiddenInput.value = token;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
cap-widget {
|
cap-widget {
|
||||||
--cap-background: var(--bg-color);
|
--cap-background: var(--bg-color);
|
||||||
--cap-border-color: rgba(255,255,255,0);
|
--cap-border-color: rgba(255, 255, 255, 0);
|
||||||
--cap-border-radius: 0;
|
--cap-border-radius: 0;
|
||||||
--cap-widget-height: initial;
|
--cap-widget-height: initial;
|
||||||
--cap-widget-width: 100%;
|
--cap-widget-width: 100%;
|
||||||
|
|
@ -121,43 +114,100 @@ const state = (Astro.request.method === "POST")? await handlePost() : generateIn
|
||||||
<Layout>
|
<Layout>
|
||||||
<title slot="head">Home</title>
|
<title slot="head">Home</title>
|
||||||
<Fragment slot="content">
|
<Fragment slot="content">
|
||||||
<h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2>
|
<h2>
|
||||||
{state.state !== "complete" && <form method="post" x-data="{}">
|
Contact <iconify-icon
|
||||||
<div>
|
icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon>
|
||||||
<p>Use the below form to shoot me a quick text!</p>
|
</h2>
|
||||||
{state.error && <p>{state.error}</p>}
|
{
|
||||||
</div>
|
(nextAction != "complete" && (
|
||||||
<fieldset id="send_otp" disabled={state.state !== "initial"}>
|
<form method="post" x-data="{}" action={actions.contact.submitForm}>
|
||||||
<label for="name">
|
<div>
|
||||||
Name
|
{(result?.error && (
|
||||||
<input type="text" id="name" name="name" value="Bad Blocks" />
|
<p class="error">
|
||||||
{"name" in state.fields && state.fields.name.hasError && <p>{state.fields.name.error}</p>}
|
Unable to send {nextAction == "send_otp" ? "OTP" : "message"}.
|
||||||
</label>
|
Please correct any errors and try again.
|
||||||
<label for="phone">
|
</p>
|
||||||
Phone
|
)) || <p>Use the below form to shoot me a quick text!</p>}
|
||||||
<input type="text" id="phone" name="phone" value="206-745-2154" />
|
</div>
|
||||||
{"phone" in state.fields && state.fields.phone.hasError && <p>{state.fields.phone.error}</p>}
|
<fieldset id="send_otp" disabled={nextAction != "send_otp"}>
|
||||||
</label>
|
<label for="name">
|
||||||
<label for="msg">
|
Name
|
||||||
Msg
|
<input
|
||||||
<textarea id="msg" name="msg">I think badblocks rocks! A quick brown fox jumped over the lazy dog!</textarea>
|
type="text"
|
||||||
{"msg" in state.fields && state.fields.msg.hasError && <p>{state.fields.msg.error}</p>}
|
id="name"
|
||||||
</label>
|
name="name"
|
||||||
</fieldset>
|
aria-describedby="name"
|
||||||
<button id="send_otp" name="action" value="send_otp" type="submit" disabled={state.state !== "initial"}>Verify Your Number!</button>
|
placeholder="Bad Blocks"
|
||||||
<fieldset id="send_msg" disabled={state.state !== "otp_sent"}>
|
/>
|
||||||
<label for="otp">
|
{error.name && <p id="error_name">{error.name.join(",")}</p>}
|
||||||
Code
|
</label>
|
||||||
<input type="text" id="otp" name="otp" placeholder="123456" />
|
<label for="phone">
|
||||||
{"otp" in state.fields && state.fields.otp.hasError && <p>{state.fields.otp.error}</p>}
|
Phone
|
||||||
</label>
|
<input
|
||||||
</fieldset>
|
type="text"
|
||||||
<button id="send_msg" name="action" value="send_msg" type="submit" disabled={state.state !== "otp_sent"}>Text Me!</button>
|
id="phone"
|
||||||
<label for="captcha">
|
name="phone"
|
||||||
Captcha <span id="captcha-credits"></span>
|
aria-describedby="error_phone"
|
||||||
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
|
placeholder="555-555-5555"
|
||||||
{"captcha" in state.fields && state.fields.captcha.hasError && <p>{state.fields.captcha.error}</p>}
|
/>
|
||||||
</label>
|
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
|
||||||
</form> || <p>Your message has been sent successfully!</p>}
|
</label>
|
||||||
|
<label for="msg">
|
||||||
|
Msg
|
||||||
|
<textarea
|
||||||
|
id="msg"
|
||||||
|
name="msg"
|
||||||
|
aria-describedby="error_msg"
|
||||||
|
placeholder="I think badblocks rocks!"
|
||||||
|
/>
|
||||||
|
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
id="send_otp"
|
||||||
|
name="action"
|
||||||
|
value="send_otp"
|
||||||
|
type="submit"
|
||||||
|
disabled={nextAction != "send_otp"}
|
||||||
|
>
|
||||||
|
Verify Your Number!
|
||||||
|
</button>
|
||||||
|
<fieldset id="send_msg" disabled={nextAction != "send_msg"}>
|
||||||
|
<label for="otp">
|
||||||
|
Code
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="otp"
|
||||||
|
name="otp"
|
||||||
|
aria-describedby="error_otp"
|
||||||
|
placeholder="123456"
|
||||||
|
/>
|
||||||
|
{error.otp && <p id="error_otp">{error.otp.join(",")}</p>}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
id="send_msg"
|
||||||
|
name="action"
|
||||||
|
value="send_msg"
|
||||||
|
type="submit"
|
||||||
|
disabled={nextAction != "send_msg"}
|
||||||
|
>
|
||||||
|
Text Me!
|
||||||
|
</button>
|
||||||
|
<label for="captcha">
|
||||||
|
Captcha <span id="captcha-credits" />
|
||||||
|
<cap-widget
|
||||||
|
id="captcha"
|
||||||
|
data-cap-api-endpoint="/cap/"
|
||||||
|
aria-describedby="error_captcha"
|
||||||
|
/>
|
||||||
|
{error.captcha && (
|
||||||
|
<p id="error_captcha">{error.captcha.join(",")}</p>
|
||||||
|
)}
|
||||||
|
<input type="hidden" id="captcha" name="captcha" />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
)) || <p>Your message has been sent successfully!</p>
|
||||||
|
}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,451 +0,0 @@
|
||||||
import type { APIContext, APIRoute, AstroSession } 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";
|
|
||||||
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: string | undefined,
|
|
||||||
): Promise<ContactForm.SendSMSResult> {
|
|
||||||
if (!phone) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Phone number is required.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
error: "Verification code failed to send.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMsg(
|
|
||||||
name: string | undefined,
|
|
||||||
phone: string | undefined,
|
|
||||||
otp: string | undefined,
|
|
||||||
msg: string | undefined,
|
|
||||||
): Promise<ContactForm.SendSMSResult> {
|
|
||||||
if (!name || !phone || !otp || !msg) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "SendMsg: Missing required fields",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = `Web message from ${name} ( ${phone} ):\n\n"${msg}"`;
|
|
||||||
|
|
||||||
const isVerified = verifyOtp(phone, OTP_SALT, otp);
|
|
||||||
if (!isVerified) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "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,
|
|
||||||
error: "Message failed to send.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ALL: APIRoute = () => {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
message: "Invalid HTTP method.",
|
|
||||||
field: "form",
|
|
||||||
}),
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function validateFields<K extends ContactForm.FieldKey>(
|
|
||||||
unsafe: ContactForm.Fields<K>,
|
|
||||||
): Promise<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 "otp": {
|
|
||||||
if (!sixDigitsOnlyRegex.test(value)) {
|
|
||||||
error = "OTP code invalid.";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "captcha": {
|
|
||||||
const capValidation = await CapServer.validateToken(value);
|
|
||||||
if (!capValidation.success) {
|
|
||||||
error = "Invalid captcha token.";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
fields[field] = { hasError: true, error };
|
|
||||||
} else {
|
|
||||||
fields[field] = { hasError: false, value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fields as ContactForm.Fields<K>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateInitialState(error?: string): ContactForm.State {
|
|
||||||
return (
|
|
||||||
!error
|
|
||||||
? {
|
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
hasError: false,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
error,
|
|
||||||
hasError: true,
|
|
||||||
}
|
|
||||||
) as ContactForm.State;
|
|
||||||
}
|
|
||||||
|
|
||||||
const respondWithState = (state: ContactForm.State) =>
|
|
||||||
new Response(JSON.stringify(state), {
|
|
||||||
status: state.hasError ? 400 : 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const POST: APIRoute = async (Astro: APIContext) => {
|
|
||||||
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, Astro);
|
|
||||||
return respondWithState(finalState);
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error
|
|
||||||
? "Unexpected POST error: " + error.message
|
|
||||||
: "Unexpected POST error.";
|
|
||||||
|
|
||||||
return respondWithState(generateInitialState(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");
|
|
||||||
|
|
||||||
if (!action) {
|
|
||||||
throw "Invalid action";
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: session.get returns undefined always
|
|
||||||
if (action == "send_otp" || action == "send_msg") {
|
|
||||||
fields.name = {
|
|
||||||
hasError: false,
|
|
||||||
value: await (action === "send_msg"
|
|
||||||
? session.get("name")
|
|
||||||
: data.get("name")),
|
|
||||||
};
|
|
||||||
fields.phone = {
|
|
||||||
hasError: false,
|
|
||||||
value: await (action === "send_msg"
|
|
||||||
? session.get("phone")
|
|
||||||
: data.get("phone")),
|
|
||||||
};
|
|
||||||
fields.msg = {
|
|
||||||
hasError: false,
|
|
||||||
value: await (action === "send_msg"
|
|
||||||
? session.get("msg")
|
|
||||||
: data.get("msg")),
|
|
||||||
};
|
|
||||||
fields.captcha = {
|
|
||||||
hasError: false,
|
|
||||||
value: await data.get("cap-token"),
|
|
||||||
};
|
|
||||||
if (action === "send_msg") {
|
|
||||||
fields.otp = {
|
|
||||||
hasError: false,
|
|
||||||
value: await data.get("otp"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: action,
|
|
||||||
fields,
|
|
||||||
hasError: false,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
hasError: true,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? "Unexpected processRequest error: " + error.message
|
|
||||||
: "Unexpected processRequest error.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextState(state: ContactForm.State): ContactForm.State {
|
|
||||||
if (state.hasError) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next = {
|
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
hasError: false,
|
|
||||||
};
|
|
||||||
switch (state.state) {
|
|
||||||
case "send_otp":
|
|
||||||
next.state = "otp_sent";
|
|
||||||
break;
|
|
||||||
case "send_msg":
|
|
||||||
next.state = "complete";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return next as ContactForm.State;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevState(state: ContactForm.State): ContactForm.State {
|
|
||||||
let next = {
|
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
hasError: state.hasError,
|
|
||||||
};
|
|
||||||
switch (state.state) {
|
|
||||||
case "send_otp":
|
|
||||||
next.state = "initial";
|
|
||||||
break;
|
|
||||||
case "send_msg":
|
|
||||||
next.state = "otp_sent";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return next as ContactForm.State;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateState(
|
|
||||||
state: ContactForm.State,
|
|
||||||
): Promise<ContactForm.State> {
|
|
||||||
try {
|
|
||||||
state.fields = await validateFields(state.fields);
|
|
||||||
// if state.fields has any errors, set hasError on state too and set a message
|
|
||||||
return state;
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
hasError: true,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? "Unexpected validateState error: " + error.message
|
|
||||||
: "Unexpected validateState error.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runStateAction(
|
|
||||||
state: ContactForm.State,
|
|
||||||
Astro: APIContext,
|
|
||||||
): Promise<ContactForm.State> {
|
|
||||||
const { session } = Astro;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (state.state === "send_otp" || state.state === "send_msg") {
|
|
||||||
const name = state.fields.name.value;
|
|
||||||
const phone = state.fields.phone.value;
|
|
||||||
const msg = state.fields.msg.value;
|
|
||||||
const otp =
|
|
||||||
state.state === "send_msg" ? state.fields.otp.value : undefined;
|
|
||||||
|
|
||||||
let result;
|
|
||||||
switch (state.state) {
|
|
||||||
case "send_otp":
|
|
||||||
result = await sendOtp(phone);
|
|
||||||
if (result.success) {
|
|
||||||
session?.set("name", name);
|
|
||||||
session?.set("phone", phone);
|
|
||||||
session?.set("msg", msg);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "send_msg":
|
|
||||||
result = await sendMsg(name, phone, msg, otp);
|
|
||||||
if (result.success) {
|
|
||||||
session?.delete("name");
|
|
||||||
session?.delete("phone");
|
|
||||||
session?.delete("msg");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!result.success) {
|
|
||||||
state.hasError = true;
|
|
||||||
state.error = result.error;
|
|
||||||
state = prevState(state);
|
|
||||||
} else {
|
|
||||||
state = nextState(state);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return generateInitialState("Invalid action.");
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
hasError: true,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? "Unexpected runAction error: " + error.message
|
|
||||||
: "Unexpected runAction error.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
export type FieldKey = "name" | "phone" | "msg" | "otp" | "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<never>;
|
|
||||||
|
|
||||||
export type SendMsgState = {
|
|
||||||
state: "send_msg";
|
|
||||||
} & BaseState<"name" | "phone" | "msg" | "otp" | "captcha">;
|
|
||||||
|
|
||||||
export type CompleteState = {
|
|
||||||
state: "complete";
|
|
||||||
} & BaseState<never>;
|
|
||||||
|
|
||||||
export type State =
|
|
||||||
| InitialState
|
|
||||||
| SendOtpState
|
|
||||||
| OtpSentState
|
|
||||||
| SendMsgState
|
|
||||||
| CompleteState;
|
|
||||||
|
|
||||||
export type SMSResultSuccess = {
|
|
||||||
success: true;
|
|
||||||
expiresInSeconds?: number;
|
|
||||||
};
|
|
||||||
export type SMSResultFailure = {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
export type SendSMSResult = SMSResultSuccess | SMSResultFailure;
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
"include": [".astro/types.d.ts", "**/*", ".prettierrc.mjs"],
|
||||||
"exclude": ["dist"],
|
"exclude": ["dist"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"@layouts/*": ["./src/layouts/*"],
|
"@layouts/*": ["./src/layouts/*"],
|
||||||
"@lib/*": ["./src/lib/*"],
|
"@lib/*": ["./src/lib/*"],
|
||||||
"@pages/*": ["./src/pages/*"],
|
"@pages/*": ["./src/pages/*"],
|
||||||
"@types/*": ["./src/types/*"],
|
"@actions/*": ["./src/actions/*"],
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue