feat: add contact form with SMS OTP verification

This commit is contained in:
badblocks 2025-07-17 19:34:29 -07:00
parent 91b162fb44
commit 3874443c34
No known key found for this signature in database
14 changed files with 729 additions and 54 deletions

View file

@ -0,0 +1,97 @@
import { verifyTOTP } from "../utils/totp";
import { createSmsGatewayClient } from "../lib/sms-gateway";
import { isRateLimited, recordSubmission } from "../utils/rate-limiter.js";
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
const {
name,
message: userMessage,
phoneNumber: rawPhoneNumber,
code,
} = await readBody(event);
let phoneNumber;
try {
phoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
} catch (error) {
throw createError({ statusCode: 400, statusMessage: error.message });
}
// --- Input Validation ---
if (!name || !userMessage || !code) {
throw createError({
statusCode: 400,
statusMessage: "All fields are required.",
});
}
// Prevent abuse by checking rate limit before doing anything
if (isRateLimited(phoneNumber)) {
throw createError({
statusCode: 429,
statusMessage:
"You have already sent a message within the last week. Please try again later.",
});
}
if (userMessage.length > 140) {
throw createError({
statusCode: 400,
statusMessage: "Message cannot be longer than 140 characters.",
});
}
const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/;
if (!printableAsciiRegex.test(userMessage)) {
throw createError({
statusCode: 400,
statusMessage: "Message contains non-ASCII or non-printable characters.",
});
}
// --- Server Configuration Check ---
if (!config.myPhoneNumber || !config.superSecretSalt) {
console.error(
"Server is not fully configured. MY_PHONE_NUMBER and SUPER_SECRET_SALT are required.",
);
throw createError({
statusCode: 500,
statusMessage: "A server configuration error occurred.",
});
}
// --- Verification ---
const isVerified = verifyTOTP(phoneNumber, config.superSecretSalt, code);
if (!isVerified) {
throw createError({
statusCode: 401,
statusMessage:
"Your verification code is invalid or has expired. Please try again.",
});
}
// --- Send Message ---
try {
const api = createSmsGatewayClient(config);
const finalMessage = `New message from ${name} ( ${phoneNumber} ) via your portfolio:\n\n"${userMessage}"`;
const message = {
phoneNumbers: [config.myPhoneNumber],
message: finalMessage,
};
const state = await api.send(message);
// On success, record the submission time to start the rate-limiting period.
recordSubmission(phoneNumber);
return { success: true, messageId: state.id };
} catch (error) {
console.error("Failed to send message:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to send message.",
});
}
});

View file

@ -0,0 +1,56 @@
import { generateTOTP } from "../utils/totp";
import { createSmsGatewayClient } from "../lib/sms-gateway";
import { isRateLimited } from "../utils/rate-limiter.js";
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const { phoneNumber: rawPhoneNumber } = await readBody(event);
let normalizedPhoneNumber;
try {
normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
} catch (error) {
// The validator throws an error with a user-friendly message.
throw createError({ statusCode: 400, statusMessage: error.message });
}
// Prevent abuse by checking rate limit before sending an SMS
if (isRateLimited(normalizedPhoneNumber)) {
throw createError({
statusCode: 429,
statusMessage:
"You have already sent a message within the last week. Please try again later.",
});
}
if (!config.superSecretSalt) {
console.error("SUPER_SECRET_SALT is not configured on the server.");
throw createError({
statusCode: 500,
statusMessage: "A server configuration error occurred.",
});
}
try {
const api = createSmsGatewayClient(config);
const otp = generateTOTP(normalizedPhoneNumber, config.superSecretSalt);
const step_min = Math.floor(getTOTPstep() / 60);
const step_sec = getTOTPstep() % 60;
const message = {
phoneNumbers: [normalizedPhoneNumber],
message: `${otp} is your verification code. This code is valid for ${step_min}m${step_sec}s.`,
};
const state = await api.send(message);
return { success: true, messageId: state.id };
} catch (error) {
console.error("Failed to send OTP:", error);
throw createError({
statusCode: 500,
statusMessage:
"An error occurred while trying to send the verification code.",
});
}
});

View file

@ -0,0 +1,59 @@
import { verifyTOTP } from "../utils/totp";
import { isRateLimited } from "../utils/rate-limiter.js";
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const { phoneNumber: rawPhoneNumber, code } = await readBody(event);
let normalizedPhoneNumber;
try {
normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
} catch (error) {
// The validator throws an error with a user-friendly message.
throw createError({ statusCode: 400, statusMessage: error.message });
}
if (!code) {
throw createError({
statusCode: 400,
statusMessage: "Verification code is required.",
});
}
// Prevent abuse by checking rate limit before doing anything
if (isRateLimited(normalizedPhoneNumber)) {
throw createError({
statusCode: 429,
statusMessage:
"You have already sent a message within the last week. Please try again later.",
});
}
// Check for necessary server configuration.
if (!config.superSecretSalt) {
console.error("SUPER_SECRET_SALT is not configured on the server.");
// This is an internal server error, so we don't expose details to the client.
throw createError({
statusCode: 500,
statusMessage: "A server configuration error occurred.",
});
}
const isValid = verifyTOTP(
normalizedPhoneNumber,
config.superSecretSalt,
code,
);
if (isValid) {
// In a stateful app, one might set a session cookie here.
return { success: true };
} else {
// The code is incorrect or has expired.
throw createError({
statusCode: 401, // Unauthorized
statusMessage: "Invalid or expired verification code.",
});
}
});

45
server/lib/http-client.js Normal file
View file

@ -0,0 +1,45 @@
/**
* A simple HTTP client wrapper around the global fetch function.
* This is used by the android-sms-gateway client.
*/
export const httpFetchClient = {
get: async (url, headers) => {
const response = await fetch(url, {
method: "GET",
headers,
});
if (!response.ok) {
const errorBody = await response.text();
console.error("Gateway GET error:", errorBody);
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
post: async (url, body, headers) => {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorBody = await response.text();
console.error("Gateway POST error:", errorBody);
throw new Error(
`HTTP error! status: ${response.status}, body: ${errorBody}`,
);
}
return response.json();
},
delete: async (url, headers) => {
const response = await fetch(url, {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorBody = await response.text();
console.error("Gateway DELETE error:", errorBody);
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
};

42
server/lib/sms-gateway.js Normal file
View file

@ -0,0 +1,42 @@
import Client from "android-sms-gateway";
import { httpFetchClient } from "./http-client";
/**
* Creates and configures an instance of the android-sms-gateway client.
* It centralizes the client instantiation and ensures that all necessary
* configuration for the gateway is present.
*
* @param {object} config The runtime configuration object from `useRuntimeConfig`.
* It should contain `androidSmsGatewayLogin`, `androidSmsGatewayPassword`,
* and `androidSmsGatewayUrl`.
* @returns {Client} A configured instance of the SMS gateway client.
* @throws {Error} If the required gateway configuration is missing, prompting
* a 500-level error in the calling API endpoint.
*/
export function createSmsGatewayClient(config) {
const {
androidSmsGatewayLogin,
androidSmsGatewayPassword,
androidSmsGatewayUrl,
} = config;
if (
!androidSmsGatewayLogin ||
!androidSmsGatewayPassword ||
!androidSmsGatewayUrl
) {
console.error(
"SMS Gateway service is not configured. Missing required environment variables for the gateway.",
);
// This indicates a critical server misconfiguration. The calling API endpoint
// should handle this and return a generic 500 error to the client.
throw new Error("Server is not configured for sending SMS.");
}
return new Client(
androidSmsGatewayLogin,
androidSmsGatewayPassword,
httpFetchClient,
androidSmsGatewayUrl,
);
}

View file

@ -0,0 +1,36 @@
/**
* Normalizes and validates a phone number string based on specific rules.
*
* The normalization process is as follows:
* 1. All non-digit characters are stripped from the input string.
* 2. If the resulting number has a leading '1', it is removed.
*
* After normalization, the function validates that the resulting number
* is exactly 10 digits long.
*
* @param {string} rawPhoneNumber The raw phone number string provided by the user.
* @returns {string} The normalized, 10-digit phone number.
* @throws {Error} Throws an error if the phone number is invalid after normalization,
* allowing API endpoints to catch it and return a proper HTTP status.
*/
export function normalizeAndValidatePhoneNumber(rawPhoneNumber) {
if (!rawPhoneNumber || typeof rawPhoneNumber !== "string") {
throw new Error("Phone number is required.");
}
// 1. Strip all non-digit characters.
const digitsOnly = rawPhoneNumber.replace(/\D/g, "");
// 2. If the number starts with a '1', remove it.
let numberToValidate = digitsOnly;
if (numberToValidate.startsWith("1")) {
numberToValidate = numberToValidate.substring(1);
}
// 3. Check if the resulting number is exactly 10 digits long.
if (numberToValidate.length !== 10) {
throw new Error("Please provide a valid 10-digit phone number.");
}
return numberToValidate;
}

View file

@ -0,0 +1,33 @@
// A shared, in-memory store for tracking submission timestamps.
const submissionTimestamps = new Map();
// The rate-limiting period (1 week in milliseconds).
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Checks if a given phone number is currently rate-limited.
* A phone number is considered rate-limited if a successful submission
* was recorded within the last week.
*
* @param {string} phoneNumber The phone number to check.
* @returns {boolean} True if the number is rate-limited, false otherwise.
*/
export function isRateLimited(phoneNumber) {
const lastSubmissionTime = submissionTimestamps.get(phoneNumber);
if (!lastSubmissionTime) {
return false; // Not in the map, so not rate-limited.
}
// Check if the time elapsed since the last submission is less than one week.
return Date.now() - lastSubmissionTime < ONE_WEEK_IN_MS;
}
/**
* Records a successful submission for a given phone number by setting
* the current timestamp. This will start the 1-week rate-limiting period.
*
* @param {string} phoneNumber The phone number to record the submission for.
*/
export function recordSubmission(phoneNumber) {
submissionTimestamps.set(phoneNumber, Date.now());
}

60
server/utils/totp.js Normal file
View file

@ -0,0 +1,60 @@
import { authenticator } from "otplib";
import { createHash } from "crypto";
// These settings must be consistent between generation and verification.
authenticator.options = {
step: 60, // OTP is valid for 1 minute per window
window: [5, 1], // Allow tokens from 5 previous and 1 future time-steps.
digits: 6,
};
/**
* Derives a stable, stateless secret for a user from their phone number
* and a global salt.
* @param {string} phoneNumber The user's phone number.
* @param {string} salt The global super secret salt.
* @returns {string} A hex-encoded secret string.
*/
function getUserSecret(phoneNumber, salt) {
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");
}
/**
* Generates a Time-based One-Time Password (TOTP) for a given phone number.
* @param {string} phoneNumber The user's phone number.
* @param {string} salt The global super secret salt.
* @returns {string} The generated 6-digit OTP.
*/
export function generateTOTP(phoneNumber, salt) {
const userSecret = getUserSecret(phoneNumber, salt);
return authenticator.generate(userSecret);
}
/**
* Verifies a TOTP token submitted by a user.
* @param {string} phoneNumber The user's phone number.
* @param {string} salt The global super secret salt.
* @param {string} token The 6-digit OTP token submitted by the user.
* @returns {boolean} True if the token is valid, false otherwise.
*/
export function verifyTOTP(phoneNumber, salt, token) {
const userSecret = getUserSecret(phoneNumber, salt);
// The `verify` method checks the token against the current and adjacent
// time-steps, as configured in the options.
return authenticator.verify({ token, secret: userSecret });
}
/**
* Get the current TOTP step in seconds.
* @returns {number} The current TOTP step in seconds.
*/
export function getTOTPstep() {
return authenticator.options.step;
}