Break out contact form into separate component and implement OTP and rate
limiting, add iconify-icon dependency Add src/components/ContactForm.astro implementing OTP verify/send flows and server-side validation Add src/lib/Otp.ts (otplib-backed) with per-hour OTP and per-week message rate limits and helper functions Expose OTP_SUPER_SECRET_SALT in astro.config and add otplib Rename src/lib/cap.ts to src/lib/CapAdapter.ts and update imports Replace inline contact page logic with ContactForm and adjust SMS client Add iconify-icon dependency
This commit is contained in:
parent
d5a7887ad2
commit
72e57fb7ff
10 changed files with 516 additions and 129 deletions
|
|
@ -41,6 +41,10 @@ export default defineConfig({
|
||||||
context: "server",
|
context: "server",
|
||||||
access: "secret",
|
access: "secret",
|
||||||
}),
|
}),
|
||||||
|
OTP_SUPER_SECRET_SALT: envField.string({
|
||||||
|
context: "server",
|
||||||
|
access: "secret",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: [alpinejs(), sitemap(), htmx(), db()],
|
integrations: [alpinejs(), sitemap(), htmx(), db()],
|
||||||
|
|
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -28,6 +28,8 @@
|
||||||
"astro": "^5.16.6",
|
"astro": "^5.16.6",
|
||||||
"astro-htmx": "^1.0.6",
|
"astro-htmx": "^1.0.6",
|
||||||
"htmx.org": "^2.0.8",
|
"htmx.org": "^2.0.8",
|
||||||
|
"iconify-icon": "^3.0.2",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
351
src/components/ContactForm.astro
Normal file
351
src/components/ContactForm.astro
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
---
|
||||||
|
import SmsClient from "@lib/SmsGatewayClient.ts";
|
||||||
|
import CapServer from "@lib/CapAdapter";
|
||||||
|
import Otp, { verifyOtp } from "@lib/Otp.ts"
|
||||||
|
import { OTP_SUPER_SECRET_SALT, ANDROID_SMS_GATEWAY_RECIPIENT_PHONE } from "astro:env/server";
|
||||||
|
|
||||||
|
type FormErrors = { name: string; phone: string; msg: string; code: string; captcha: string; form: string };
|
||||||
|
|
||||||
|
type ValidationFailure = { success: false; message: string; field?: keyof FormErrors };
|
||||||
|
type ValidationSuccess<T = void> = { success: true; data: T; message?: string };
|
||||||
|
type ValidationResult<T = void> = ValidationSuccess<T> | ValidationFailure;
|
||||||
|
|
||||||
|
const OTP_SALT = OTP_SUPER_SECRET_SALT;
|
||||||
|
if (!OTP_SALT) {
|
||||||
|
throw new Error("OTP secret salt configuration is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSafeAndCheckPhoneNumber(unsafePhoneNumber: string): ValidationResult<{ phoneNumber: string }> {
|
||||||
|
const trimmed = unsafePhoneNumber.trim();
|
||||||
|
const phoneNumberResult = Otp.validatePhoneNumber(trimmed);
|
||||||
|
|
||||||
|
if (!phoneNumberResult.success || typeof phoneNumberResult.validatedPhoneNumber !== 'string') {
|
||||||
|
return { success: false, message: "Invalid phone number.", field: "phone" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { validatedPhoneNumber } = phoneNumberResult;
|
||||||
|
|
||||||
|
if (Otp.isRateLimitedForOtp(validatedPhoneNumber)) {
|
||||||
|
return { success: false, message: "Too many OTP requests. Please try again later.", field: "phone" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Otp.isRateLimitedForMsgs(validatedPhoneNumber)) {
|
||||||
|
return { success: false, message: "Too many messages. Please try again later.", field: "phone" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: { phoneNumber: validatedPhoneNumber } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSafeAndCheck(unsafeName: string, unsafePhoneNumber: string, unsafeCode: string, unsafeMsg: string): ValidationResult<{ name: string; phoneNumber: string; code: string; msg: string }> {
|
||||||
|
const phoneNumberResult = makeSafeAndCheckPhoneNumber(unsafePhoneNumber);
|
||||||
|
|
||||||
|
if (!phoneNumberResult.success) {
|
||||||
|
return phoneNumberResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { phoneNumber } = phoneNumberResult.data;
|
||||||
|
const name = unsafeName.trim();
|
||||||
|
const msg = unsafeMsg.trim();
|
||||||
|
const code = unsafeCode.trim();
|
||||||
|
|
||||||
|
const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/;
|
||||||
|
const sixDigitsOnlyRegex = /^[0-9]{6}$/;
|
||||||
|
|
||||||
|
if (!sixDigitsOnlyRegex.test(code)) {
|
||||||
|
return { success: false, message: "OTP code invalid.", field: "code" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!printableAsciiRegex.test(name)) {
|
||||||
|
return { success: false, message: "Name contains non-ASCII or non-printable characters.", field: "name" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!printableAsciiRegex.test(msg)) {
|
||||||
|
return { success: false, message: "Message contains non-ASCII or non-printable characters.", field: "msg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length < 2 || name.length > 25) {
|
||||||
|
return { success: false, message: "Please enter a valid name.", field: "name" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.length > 500) {
|
||||||
|
return { success: false, message: "Message cannot be longer than 500 characters.", field: "msg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.length < 10) {
|
||||||
|
return { success: false, message: "Message is too short.", field: "msg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/([a-zA-Z])\1{4,}/.test(msg)) {
|
||||||
|
return { success: false, message: "Message contains excessive repeated characters.", field: "msg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const uppercaseRatio = (msg.match(/[A-Z]/g) || []).length / msg.length;
|
||||||
|
if (uppercaseRatio > 0.25) {
|
||||||
|
return { success: false, message: "Message contains excessive uppercase text.", field: "msg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: { name, phoneNumber, code, msg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOtp(unsafePhoneNumber: string): Promise<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;
|
||||||
|
|
||||||
|
if (Astro.request.method !== "POST") {
|
||||||
|
return { errors, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await Astro.request.formData();
|
||||||
|
const rawCapToken = data.get("cap-token");
|
||||||
|
const rawAction = data.get("action");
|
||||||
|
const rawName = data.get("name");
|
||||||
|
const rawPhone = data.get("phone");
|
||||||
|
const rawMsg = data.get("msg");
|
||||||
|
const rawCode = data.get("code");
|
||||||
|
|
||||||
|
const submittedFields = [rawCapToken, rawAction, rawName, rawPhone, rawMsg, rawCode];
|
||||||
|
if (!submittedFields.every((field): field is string => typeof field === "string")) {
|
||||||
|
throw new Error("Invalid form submission.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [capToken, action, name, phone, msg, code] = submittedFields;
|
||||||
|
|
||||||
|
const capValidation = await CapServer.validateToken(capToken);
|
||||||
|
if (!capValidation.success) {
|
||||||
|
errors.captcha = "Invalid captcha token.";
|
||||||
|
return { errors, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !== "send_otp" && action !== "send_msg") {
|
||||||
|
errors.form = "Invalid action.";
|
||||||
|
return { errors, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = action === "send_otp"
|
||||||
|
? await sendOtp(phone)
|
||||||
|
: await sendMsg(name, phone, code, msg);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const target = result.field ?? "form";
|
||||||
|
errors[target] = result.message;
|
||||||
|
return { errors, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "send_otp") {
|
||||||
|
errors.form = result.message ?? "";
|
||||||
|
return { errors, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
success = true;
|
||||||
|
return { errors, success };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errors.form = error.message;
|
||||||
|
} else {
|
||||||
|
errors.form = "An unexpected error occurred.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors, success };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { errors, success } = await handleFormRequest();
|
||||||
|
---
|
||||||
|
<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 0 0 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"
|
||||||
|
"code code submit submit";
|
||||||
|
}
|
||||||
|
#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[name="action"] {
|
||||||
|
height: 39px;
|
||||||
|
margin: 29px 0 10px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
button[name="action"]#send_otp {
|
||||||
|
grid-area: verify;
|
||||||
|
}
|
||||||
|
button[name="action"]#send_msg {
|
||||||
|
grid-area: submit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2>
|
||||||
|
{!success && <form method="post" x-data="{}">
|
||||||
|
<div>
|
||||||
|
<p>Use the below form to shoot me a quick text!</p>
|
||||||
|
{errors.form && <p>{errors.form}</p>}
|
||||||
|
</div>
|
||||||
|
<label for="name">
|
||||||
|
Name
|
||||||
|
<input type="text" id="name" name="name" value="Bad Blocks" />
|
||||||
|
{errors.name && <p>{errors.name}</p>}
|
||||||
|
</label>
|
||||||
|
<label for="phone">
|
||||||
|
Phone
|
||||||
|
<input type="text" id="phone" name="phone" value="555-555-5555" />
|
||||||
|
{errors.phone && <p>{errors.phone}</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>
|
||||||
|
{errors.msg && <p>{errors.msg}</p>}
|
||||||
|
</label>
|
||||||
|
<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>
|
||||||
|
<label for="code">
|
||||||
|
Code
|
||||||
|
<input type="text" id="code" name="code" placeholder="123456" />
|
||||||
|
{errors.code && <p>{errors.code}</p>}
|
||||||
|
</label>
|
||||||
|
<button id="send_otp" name="action" value="send_otp" type="submit">Verify Your Number!</button>
|
||||||
|
<button id="send_msg" name="action" value="send_msg" type="submit">Send It!</button>
|
||||||
|
</form> || <p>Your message has been sent successfully!</p>}
|
||||||
152
src/lib/Otp.ts
Normal file
152
src/lib/Otp.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { authenticator } from "otplib";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
const submissionTimestamps = new Map();
|
||||||
|
const otpRequestTimestamps = new Map();
|
||||||
|
const ONE_WEEK_IN_MS: number = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
|
||||||
|
const MAX_OTP_REQUESTS_PER_HOUR: number = 3;
|
||||||
|
const MAX_MESSAGES_PER_WEEK: number = 3;
|
||||||
|
const OTP_STEP_IN_SEC: number = 60;
|
||||||
|
const VALID_PAST_OTP_STEPS: number = 5;
|
||||||
|
const VALID_FUTURE_OTP_STEPS: number = 1;
|
||||||
|
const OTP_NUM_DIGITS: number = 6;
|
||||||
|
|
||||||
|
authenticator.options = {
|
||||||
|
step: OTP_STEP_IN_SEC,
|
||||||
|
window: [VALID_PAST_OTP_STEPS, VALID_FUTURE_OTP_STEPS],
|
||||||
|
digits: OTP_NUM_DIGITS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getUserSecret(phoneNumber: string, salt: string): string {
|
||||||
|
if (!phoneNumber || !salt) {
|
||||||
|
throw new Error(
|
||||||
|
"Phone number and salt are required to generate a user secret.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(phoneNumber + salt)
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePhoneNumber(unsafePhoneNum: string) {
|
||||||
|
if (typeof unsafePhoneNum !== "string") {
|
||||||
|
return { success: false, message: "Invalid phone number." };
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafePhoneNum = unsafePhoneNum.replace(/[^0-9]/g, "").trim();
|
||||||
|
const cleanedNumber = unsafePhoneNum.startsWith("1")
|
||||||
|
? unsafePhoneNum.substring(1)
|
||||||
|
: unsafePhoneNum;
|
||||||
|
|
||||||
|
const isValidFormat = /^[2-7][0-8][0-9][2-9][0-9]{6}$/.test(cleanedNumber);
|
||||||
|
const isNotAllSameDigit = !/^(.)\1{9}$/.test(cleanedNumber);
|
||||||
|
const isNot911Number = !/^[0-9]{3}911[0-9]{4}$/.test(cleanedNumber);
|
||||||
|
const isNot555Number = !/^[0-9]{3}555[0-9]{4}$/.test(cleanedNumber);
|
||||||
|
const isNotPopSongNumber = !/^[0-9]{3}8675309$/.test(cleanedNumber);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isValidFormat &&
|
||||||
|
isNotAllSameDigit &&
|
||||||
|
isNot911Number &&
|
||||||
|
isNot555Number &&
|
||||||
|
isNotPopSongNumber
|
||||||
|
) {
|
||||||
|
return { success: true, validatedPhoneNumber: cleanedNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, validatedPhoneNumber: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateOtp(phoneNumber: string, salt: string): string {
|
||||||
|
const userSecret = getUserSecret(phoneNumber, salt);
|
||||||
|
return authenticator.generate(userSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyOtp(
|
||||||
|
phoneNumber: string,
|
||||||
|
salt: string,
|
||||||
|
token: string,
|
||||||
|
): boolean {
|
||||||
|
const userSecret = getUserSecret(phoneNumber, salt);
|
||||||
|
return authenticator.verify({ token, secret: userSecret });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOtpStep(): number {
|
||||||
|
const step = authenticator.options.step;
|
||||||
|
if (typeof step !== "number") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRateLimitedForMsgs(phoneNumber: string): boolean {
|
||||||
|
const submissionTimestampsArray = submissionTimestamps.get(phoneNumber);
|
||||||
|
if (!submissionTimestampsArray || submissionTimestampsArray.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const recentSubmissions = submissionTimestampsArray.filter(
|
||||||
|
(timestamp: number) => now - timestamp < ONE_WEEK_IN_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentSubmissions.length !== submissionTimestampsArray.length) {
|
||||||
|
submissionTimestamps.set(phoneNumber, recentSubmissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recentSubmissions.length >= MAX_MESSAGES_PER_WEEK;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordMsgSubmission(phoneNumber: string) {
|
||||||
|
const now = Date.now();
|
||||||
|
const existingSubmissions = submissionTimestamps.get(phoneNumber) || [];
|
||||||
|
|
||||||
|
const recentSubmissions = existingSubmissions.filter(
|
||||||
|
(timestamp: number) => now - timestamp < ONE_WEEK_IN_MS,
|
||||||
|
);
|
||||||
|
recentSubmissions.push(now);
|
||||||
|
|
||||||
|
submissionTimestamps.set(phoneNumber, recentSubmissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRateLimitedForOtp(phoneNumber: string): boolean {
|
||||||
|
const requestTimestamps = otpRequestTimestamps.get(phoneNumber);
|
||||||
|
if (!requestTimestamps || requestTimestamps.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const recentRequests = requestTimestamps.filter(
|
||||||
|
(timestamp: number) => now - timestamp < ONE_HOUR_IN_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentRequests.length !== requestTimestamps.length) {
|
||||||
|
otpRequestTimestamps.set(phoneNumber, recentRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recentRequests.length >= MAX_OTP_REQUESTS_PER_HOUR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordOtpRequest(phoneNumber: string) {
|
||||||
|
const now = Date.now();
|
||||||
|
const existingRequests = otpRequestTimestamps.get(phoneNumber) || [];
|
||||||
|
|
||||||
|
const recentRequests = existingRequests.filter(
|
||||||
|
(timestamp: number) => now - timestamp < ONE_HOUR_IN_MS,
|
||||||
|
);
|
||||||
|
recentRequests.push(now);
|
||||||
|
|
||||||
|
otpRequestTimestamps.set(phoneNumber, recentRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
validatePhoneNumber,
|
||||||
|
generateOtp,
|
||||||
|
verifyOtp,
|
||||||
|
getOtpStep,
|
||||||
|
recordOtpRequest,
|
||||||
|
recordMsgSubmission,
|
||||||
|
isRateLimitedForOtp,
|
||||||
|
isRateLimitedForMsgs,
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,6 @@ import Client from "android-sms-gateway";
|
||||||
import {
|
import {
|
||||||
ANDROID_SMS_GATEWAY_LOGIN,
|
ANDROID_SMS_GATEWAY_LOGIN,
|
||||||
ANDROID_SMS_GATEWAY_PASSWORD,
|
ANDROID_SMS_GATEWAY_PASSWORD,
|
||||||
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
|
|
||||||
ANDROID_SMS_GATEWAY_URL,
|
ANDROID_SMS_GATEWAY_URL,
|
||||||
} from "astro:env/server";
|
} from "astro:env/server";
|
||||||
import httpFetchClient from "@lib/HttpFetchClient";
|
import httpFetchClient from "@lib/HttpFetchClient";
|
||||||
|
|
@ -19,9 +18,9 @@ class SmsClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSMS(message: string) {
|
async sendSMS(phoneNumber: string, message: string) {
|
||||||
const bundle = {
|
const bundle = {
|
||||||
phoneNumbers: [ANDROID_SMS_GATEWAY_RECIPIENT_PHONE], // hard-coded on purpose ;)
|
phoneNumbers: [phoneNumber],
|
||||||
message: message,
|
message: message,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import cap from "@lib/cap";
|
import cap from "@lib/CapAdapter";
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async () => {
|
||||||
try {
|
try {
|
||||||
return new Response(JSON.stringify(await cap.createChallenge()), {
|
return new Response(JSON.stringify(await cap.createChallenge()), {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import cap from "@lib/cap";
|
import cap from "@lib/CapAdapter";
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,11 @@
|
||||||
---
|
---
|
||||||
import Layout from "@layouts/BaseLayout.astro";
|
import Layout from "@layouts/BaseLayout.astro";
|
||||||
import SmsClient from "@lib/SmsGatewayClient.ts";
|
import ContactForm from "@components/ContactForm.astro";
|
||||||
import CapServer from "@lib/cap";
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const errors = { name: "", phone: "", msg: "", form: "" };
|
|
||||||
let success = false;
|
|
||||||
if (Astro.request.method === "POST") {
|
|
||||||
try {
|
|
||||||
const data = await Astro.request.formData();
|
|
||||||
const name = data.get("name")?.toString();
|
|
||||||
const capToken = data.get("cap-token")?.toString();
|
|
||||||
const phone = data.get("phone")?.toString();
|
|
||||||
const msg = data.get("msg")?.toString();
|
|
||||||
|
|
||||||
if (typeof capToken !== "string" || !(await CapServer.validateToken(capToken)).success) {
|
|
||||||
throw new Error("invalid cap token");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof name !== "string" || name.length < 1) {
|
|
||||||
errors.name += "Please enter a name. ";
|
|
||||||
}
|
|
||||||
if (typeof phone !== "string") {
|
|
||||||
errors.phone += "Phone is not valid. ";
|
|
||||||
}
|
|
||||||
if (typeof msg !== "string" || msg.length < 20) {
|
|
||||||
errors.msg += "Message must be at least 20 characters. ";
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasErrors = Object.values(errors).some(msg => msg)
|
|
||||||
if (!hasErrors) {
|
|
||||||
const smsClient = new SmsClient();
|
|
||||||
const message = "Web message from " + name + " (" + phone + "):\n\n" + msg;
|
|
||||||
const result = await smsClient.sendSMS(message);
|
|
||||||
if (!result.success) {
|
|
||||||
errors.form += "Sending SMS failed; API returned error. "
|
|
||||||
} else { success = true; }
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
errors.form += error.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
<script>import CapWidget from "@cap.js/widget";</script>
|
|
||||||
<style>
|
|
||||||
cap-widget {
|
|
||||||
--cap-background: #fdfdfd;
|
|
||||||
--cap-border-color: var(--border-color);
|
|
||||||
--cap-border-radius: 4px;
|
|
||||||
--cap-widget-height: 100%;
|
|
||||||
--cap-widget-width: 100%;
|
|
||||||
--cap-widget-padding: 10px 20px;
|
|
||||||
--cap-gap: 14px;
|
|
||||||
--cap-color: #212121;
|
|
||||||
--cap-checkbox-size: 25px;
|
|
||||||
--cap-checkbox-border: 1px solid #aaaaaad1;
|
|
||||||
--cap-checkbox-border-radius: 4px;
|
|
||||||
--cap-checkbox-background: #fafafa91;
|
|
||||||
--cap-checkbox-margin: 2px;
|
|
||||||
--cap-font: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular", "San Francisco",
|
|
||||||
"Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
|
|
||||||
--cap-spinner-color: #000;
|
|
||||||
--cap-spinner-background-color: #eee;
|
|
||||||
--cap-spinner-thickness: 5px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<title slot="head">Contact</title>
|
<title slot="head">Contact</title>
|
||||||
<style>
|
|
||||||
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 submit submit";
|
|
||||||
}
|
|
||||||
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#submit {
|
|
||||||
grid-area: submit;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<Fragment slot="content">
|
<Fragment slot="content">
|
||||||
<h2>Contact</h2>
|
<ContactForm />
|
||||||
{!success && <form method="post" x-data="{}">
|
|
||||||
<div>
|
|
||||||
<p>Use the below form to shoot me a quick text!</p>
|
|
||||||
{errors.form && <p>{errors.form}</p>}
|
|
||||||
</div>
|
|
||||||
<label for="name">
|
|
||||||
Name
|
|
||||||
<input type="text" id="name" name="name" placeholder="Bad Blocks" />
|
|
||||||
{errors.name && <p>{errors.name}</p>}
|
|
||||||
</label>
|
|
||||||
<label for="phone">
|
|
||||||
Phone
|
|
||||||
<input type="text" id="phone" name="phone" placeholder="555-555-5555" />
|
|
||||||
{errors.phone && <p>{errors.phone}</p>}
|
|
||||||
</label>
|
|
||||||
<label for="msg">
|
|
||||||
Msg
|
|
||||||
<textarea id="msg" name="msg" placeholder="I think badblocks rocks!"></textarea>
|
|
||||||
{errors.msg && <p>{errors.msg}</p>}
|
|
||||||
</label>
|
|
||||||
<label for="captcha">
|
|
||||||
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
|
|
||||||
</label>
|
|
||||||
<button id="submit" type="submit">Submit</button>
|
|
||||||
</form> || <p>Your message has been sent successfully!</p>}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue