feat: add contact form with SMS OTP verification
This commit is contained in:
parent
91b162fb44
commit
3874443c34
14 changed files with 729 additions and 54 deletions
97
server/api/send-message.post.js
Normal file
97
server/api/send-message.post.js
Normal 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.",
|
||||
});
|
||||
}
|
||||
});
|
||||
56
server/api/send-otp.post.js
Normal file
56
server/api/send-otp.post.js
Normal 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.",
|
||||
});
|
||||
}
|
||||
});
|
||||
59
server/api/verify-otp.post.js
Normal file
59
server/api/verify-otp.post.js
Normal 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.",
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue