diff --git a/astro.config.mjs b/astro.config.mjs index 12087a7..63fff73 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -41,6 +41,10 @@ export default defineConfig({ context: "server", access: "secret", }), + OTP_SUPER_SECRET_SALT: envField.string({ + context: "server", + access: "secret", + }), }, }, integrations: [alpinejs(), sitemap(), htmx(), db()], diff --git a/bun.lockb b/bun.lockb index 25bfd62..7c45111 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9b4f6c5..bb2650a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "astro": "^5.16.6", "astro-htmx": "^1.0.6", "htmx.org": "^2.0.8", + "iconify-icon": "^3.0.2", + "otplib": "^12.0.1", "typescript": "^5.9.3" }, "devDependencies": { diff --git a/src/components/ContactForm.astro b/src/components/ContactForm.astro new file mode 100644 index 0000000..3eea6bb --- /dev/null +++ b/src/components/ContactForm.astro @@ -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 = { success: true; data: T; message?: string }; +type ValidationResult = ValidationSuccess | 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 { + 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 { + 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 { + 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(); +--- + + + + +

Contact

+{!success &&
+
+

Use the below form to shoot me a quick text!

+ {errors.form &&

{errors.form}

} +
+ + + + + + + +
||

Your message has been sent successfully!

} diff --git a/src/lib/cap.ts b/src/lib/CapAdapter.ts similarity index 100% rename from src/lib/cap.ts rename to src/lib/CapAdapter.ts diff --git a/src/lib/Otp.ts b/src/lib/Otp.ts new file mode 100644 index 0000000..95969f4 --- /dev/null +++ b/src/lib/Otp.ts @@ -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, +}; diff --git a/src/lib/SmsGatewayClient.ts b/src/lib/SmsGatewayClient.ts index e8a5de4..966b408 100644 --- a/src/lib/SmsGatewayClient.ts +++ b/src/lib/SmsGatewayClient.ts @@ -2,7 +2,6 @@ import Client from "android-sms-gateway"; import { ANDROID_SMS_GATEWAY_LOGIN, ANDROID_SMS_GATEWAY_PASSWORD, - ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, ANDROID_SMS_GATEWAY_URL, } from "astro:env/server"; import httpFetchClient from "@lib/HttpFetchClient"; @@ -19,9 +18,9 @@ class SmsClient { ); } - async sendSMS(message: string) { + async sendSMS(phoneNumber: string, message: string) { const bundle = { - phoneNumbers: [ANDROID_SMS_GATEWAY_RECIPIENT_PHONE], // hard-coded on purpose ;) + phoneNumbers: [phoneNumber], message: message, }; try { diff --git a/src/pages/cap/challenge.ts b/src/pages/cap/challenge.ts index 65a4105..07f1c63 100644 --- a/src/pages/cap/challenge.ts +++ b/src/pages/cap/challenge.ts @@ -1,8 +1,8 @@ import type { APIRoute } from "astro"; -import cap from "@lib/cap"; +import cap from "@lib/CapAdapter"; export const prerender = false; -export const POST: APIRoute = async ({ request }) => { +export const POST: APIRoute = async () => { try { return new Response(JSON.stringify(await cap.createChallenge()), { status: 200, diff --git a/src/pages/cap/redeem.ts b/src/pages/cap/redeem.ts index d66f189..d8c078a 100644 --- a/src/pages/cap/redeem.ts +++ b/src/pages/cap/redeem.ts @@ -1,5 +1,5 @@ import type { APIRoute } from "astro"; -import cap from "@lib/cap"; +import cap from "@lib/CapAdapter"; export const prerender = false; export const POST: APIRoute = async ({ request }) => { diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 3ecbe81..a20be24 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -1,132 +1,11 @@ --- import Layout from "@layouts/BaseLayout.astro"; -import SmsClient from "@lib/SmsGatewayClient.ts"; -import CapServer from "@lib/cap"; +import ContactForm from "@components/ContactForm.astro"; 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; - } - } -} --- - - Contact - -

Contact

- {!success &&
-
-

Use the below form to shoot me a quick text!

- {errors.form &&

{errors.form}

} -
- - - - - -
||

Your message has been sent successfully!

} +