feat: enhance contact form security and add animated hero

This commit is contained in:
badblocks 2025-07-19 22:59:05 -07:00
parent ea18dcdb8e
commit 8497cd819d
No known key found for this signature in database
19 changed files with 320 additions and 112 deletions

View file

@ -10,6 +10,8 @@ export default defineEventHandler(async (event) => {
message: userMessage,
phoneNumber: rawPhoneNumber,
code,
interactionData,
website,
} = await readBody(event);
let phoneNumber;
@ -19,7 +21,32 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: error.message });
}
// --- Input Validation ---
if (website) {
throw createError({
statusCode: 400,
statusMessage: "Spam detected.",
});
}
if (interactionData) {
if (interactionData.timeSpent < 3000) {
throw createError({
statusCode: 400,
statusMessage: "Submission too fast.",
});
}
if (
interactionData.mouseActivity === 0 &&
interactionData.keyboardActivity === 0
) {
throw createError({
statusCode: 400,
statusMessage: "No user interaction detected.",
});
}
}
if (!name || !userMessage || !code) {
throw createError({
statusCode: 400,
@ -27,12 +54,19 @@ export default defineEventHandler(async (event) => {
});
}
// Prevent abuse by checking rate limit before doing anything
const nameRegex = /^[a-zA-Z\s'-]{2,50}$/;
if (!nameRegex.test(name.trim())) {
throw createError({
statusCode: 400,
statusMessage: "Name contains invalid characters or format.",
});
}
if (isRateLimited(phoneNumber)) {
throw createError({
statusCode: 429,
statusMessage:
"You have already sent a message within the last week. Please try again later.",
"You have reached the maximum of 3 messages per week. Please try again later.",
});
}
@ -51,7 +85,50 @@ export default defineEventHandler(async (event) => {
});
}
// --- Server Configuration Check ---
if (userMessage.trim().length < 10) {
throw createError({
statusCode: 400,
statusMessage: "Message is too short.",
});
}
const spamKeywords = [
"viagra",
"casino",
"lottery",
"winner",
"click here",
"free money",
"urgent",
"limited time",
];
const hasSpamKeywords = spamKeywords.some((keyword) =>
userMessage.toLowerCase().includes(keyword.toLowerCase()),
);
if (hasSpamKeywords) {
throw createError({
statusCode: 400,
statusMessage: "Message contains inappropriate content.",
});
}
if (/([a-zA-Z])\1{4,}/.test(userMessage)) {
throw createError({
statusCode: 400,
statusMessage: "Message contains excessive repeated characters.",
});
}
const uppercaseRatio =
(userMessage.match(/[A-Z]/g) || []).length / userMessage.length;
if (uppercaseRatio > 0.7 && userMessage.length > 10) {
throw createError({
statusCode: 400,
statusMessage: "Message contains excessive uppercase text.",
});
}
if (!config.myPhoneNumber || !config.superSecretSalt) {
console.error(
"Server is not fully configured. MY_PHONE_NUMBER and SUPER_SECRET_SALT are required.",
@ -62,7 +139,6 @@ export default defineEventHandler(async (event) => {
});
}
// --- Verification ---
const isVerified = verifyTOTP(phoneNumber, config.superSecretSalt, code);
if (!isVerified) {
throw createError({
@ -72,7 +148,6 @@ export default defineEventHandler(async (event) => {
});
}
// --- Send Message ---
try {
const api = createSmsGatewayClient(config);
const finalMessage = `New message from ${name} ( ${phoneNumber} ) via your portfolio:\n\n"${userMessage}"`;
@ -83,7 +158,6 @@ export default defineEventHandler(async (event) => {
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 };

View file

@ -1,6 +1,10 @@
import { generateTOTP } from "../utils/totp";
import { createSmsGatewayClient } from "../lib/sms-gateway";
import { isRateLimited } from "../utils/rate-limiter.js";
import {
isRateLimited,
isOtpRateLimited,
recordOtpRequest,
} from "../utils/rate-limiter.js";
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
export default defineEventHandler(async (event) => {
@ -11,16 +15,22 @@ export default defineEventHandler(async (event) => {
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 (isOtpRateLimited(normalizedPhoneNumber)) {
throw createError({
statusCode: 429,
statusMessage:
"You have reached the maximum of 3 verification code requests per hour. Please try again later.",
});
}
if (isRateLimited(normalizedPhoneNumber)) {
throw createError({
statusCode: 429,
statusMessage:
"You have already sent a message within the last week. Please try again later.",
"You have reached the maximum of 3 messages per week. Please try again later.",
});
}
@ -44,6 +54,9 @@ export default defineEventHandler(async (event) => {
};
const state = await api.send(message);
recordOtpRequest(normalizedPhoneNumber);
return { success: true, messageId: state.id };
} catch (error) {
console.error("Failed to send OTP:", error);

View file

@ -10,7 +10,6 @@ export default defineEventHandler(async (event) => {
try {
normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
} catch (error) {
// The validator throws an error with a user-friendly message.
throw createError({ statusCode: 400, statusMessage: error.message });
}
@ -21,7 +20,6 @@ export default defineEventHandler(async (event) => {
});
}
// Prevent abuse by checking rate limit before doing anything
if (isRateLimited(normalizedPhoneNumber)) {
throw createError({
statusCode: 429,
@ -30,10 +28,8 @@ export default defineEventHandler(async (event) => {
});
}
// 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.",
@ -47,10 +43,8 @@ export default defineEventHandler(async (event) => {
);
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.",