Add spam detection and manual review request feature in contact form
- Integrated Gemini AI for spam detection in the contact form API, returning a token for manual review requests if spam is detected. - Implemented a manual review UI in the Form.astro component, allowing users to submit their email and justification for review. - Updated email handler to send manual review requests to a designated email address. - Enhanced rate limiter configuration to allow more attempts in a shorter duration for better user experience. - Added new dependencies: jsonwebtoken and @types/jsonwebtoken for handling JWTs in the spam detection process.
This commit is contained in:
84
src/components/ContactForm.astro
Normal file
84
src/components/ContactForm.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
<form id="contact-form">
|
||||
<input type="email" name="email" id="email" required />
|
||||
<textarea name="message" id="message" required></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
|
||||
<div id="spam-warning" style="display:none;">
|
||||
<p>
|
||||
Your message was detected as spam and was not sent.<br>
|
||||
If you believe this is a mistake, you can request a manual review.
|
||||
</p>
|
||||
<form id="manual-review-form">
|
||||
<input type="email" id="manual-email" readonly />
|
||||
<textarea id="manual-justification" placeholder="Why is this not spam? (optional)"></textarea>
|
||||
<input type="hidden" id="manual-token" />
|
||||
<button type="submit">Request Manual Review</button>
|
||||
</form>
|
||||
<div id="manual-review-result"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const contactForm = document.getElementById('contact-form');
|
||||
if (contactForm) {
|
||||
contactForm.onsubmit = async function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this as HTMLFormElement);
|
||||
let res, data;
|
||||
try {
|
||||
res = await fetch('/api/contact', { method: 'POST', body: formData });
|
||||
data = await res.json();
|
||||
console.log('Contact API response:', data);
|
||||
} catch (_err) {
|
||||
alert('Unexpected server response.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.spam && data.token) {
|
||||
console.log('Spam detected, showing manual review UI.');
|
||||
const spamWarning = document.getElementById('spam-warning');
|
||||
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
|
||||
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
|
||||
if (spamWarning && manualEmail && manualToken) {
|
||||
spamWarning.style.display = 'block';
|
||||
manualEmail.value = String(formData.get('email'));
|
||||
manualToken.value = data.token;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'There was an error sending your message.');
|
||||
} else {
|
||||
alert('Your message was sent successfully!');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const manualReviewForm = document.getElementById('manual-review-form');
|
||||
if (manualReviewForm) {
|
||||
manualReviewForm.onsubmit = async function (e) {
|
||||
e.preventDefault();
|
||||
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
|
||||
const manualJustification = document.getElementById('manual-justification') as HTMLTextAreaElement | null;
|
||||
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
|
||||
const resultDiv = document.getElementById('manual-review-result');
|
||||
if (!manualEmail || !manualJustification || !manualToken || !resultDiv) return;
|
||||
const email = manualEmail.value;
|
||||
const justification = manualJustification.value;
|
||||
const token = manualToken.value;
|
||||
|
||||
const res = await fetch('/api/contact/manual-review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, justification, token }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
resultDiv.textContent = 'Your request for manual review has been submitted. Thank you!';
|
||||
} else {
|
||||
resultDiv.textContent = data.error || 'There was an error submitting your manual review request.';
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
@@ -128,6 +128,23 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
}
|
||||
</form>
|
||||
|
||||
<!-- Manual Review UI -->
|
||||
<div id="spam-warning" style="display:none;" class="max-w-xl mx-auto rounded-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900 border border-gray-200 dark:border-gray-700 shadow-md p-4 sm:p-6 lg:p-8 w-full mb-6">
|
||||
<p class="mb-4 text-lg font-medium text-gray-800 dark:text-gray-100">
|
||||
Your message was detected as spam and was not sent.<br>
|
||||
<span class="text-base font-normal text-gray-600 dark:text-gray-300">If you believe this is a mistake, you can request a manual review.</span>
|
||||
</p>
|
||||
<form id="manual-review-form" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-email">Please re-enter your email address for confirmation</label>
|
||||
<input type="email" id="manual-email" required placeholder="Enter your email address again" class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" />
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-justification">Why is this not spam? <span class="text-gray-400">(optional)</span></label>
|
||||
<textarea id="manual-justification" placeholder="Explain why your message is legitimate..." class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"></textarea>
|
||||
<input type="hidden" id="manual-token" />
|
||||
<button type="submit" class="mt-2 w-full py-3 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">Request Manual Review</button>
|
||||
</form>
|
||||
<div id="manual-review-result" class="mt-4 text-center text-green-700 dark:text-green-400 font-medium"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function setCsrfToken() {
|
||||
try {
|
||||
@@ -146,55 +163,97 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setCsrfToken);
|
||||
|
||||
const form = document.getElementById('contact-form');
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement | null;
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
let response, result;
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log(result.message); // Log success message
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.remove('hidden');
|
||||
}
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.add('hidden');
|
||||
}
|
||||
form.reset(); // Clear the form
|
||||
// Re-fetch CSRF token after successful submission
|
||||
setCsrfToken();
|
||||
} else {
|
||||
console.error('Error:', response.status);
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
result = await response.json();
|
||||
console.log('Contact API response:', result);
|
||||
} catch (_err) {
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) {
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
if (errorElement) errorElement.classList.remove('hidden');
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) {
|
||||
successElement.classList.add('hidden');
|
||||
}
|
||||
if (successElement) successElement.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show manual review UI if spam detected, even if status is 422
|
||||
if (result.spam && result.token) {
|
||||
console.log('Spam detected, showing manual review UI.');
|
||||
form.style.display = 'none';
|
||||
const spamWarning = document.getElementById('spam-warning');
|
||||
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
|
||||
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
|
||||
if (spamWarning && manualEmail && manualToken) {
|
||||
spamWarning.style.display = 'block';
|
||||
manualEmail.value = String(formData.get('email'));
|
||||
manualToken.value = result.token;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) errorElement.classList.remove('hidden');
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) successElement.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
const successElement = document.getElementById('form-success');
|
||||
if (successElement) successElement.classList.remove('hidden');
|
||||
const errorElement = document.getElementById('form-error');
|
||||
if (errorElement) errorElement.classList.add('hidden');
|
||||
form.reset();
|
||||
setCsrfToken();
|
||||
});
|
||||
}
|
||||
|
||||
// Manual Review Form Logic
|
||||
const manualReviewForm = document.getElementById('manual-review-form');
|
||||
if (manualReviewForm) {
|
||||
manualReviewForm.onsubmit = async function (e) {
|
||||
e.preventDefault();
|
||||
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
|
||||
const manualJustification = document.getElementById('manual-justification') as HTMLTextAreaElement | null;
|
||||
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
|
||||
const resultDiv = document.getElementById('manual-review-result');
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement | null;
|
||||
const spamWarning = document.getElementById('spam-warning');
|
||||
if (!manualEmail || !manualJustification || !manualToken || !resultDiv) return;
|
||||
const email = manualEmail.value;
|
||||
const justification = manualJustification.value;
|
||||
const token = manualToken.value;
|
||||
|
||||
const res = await fetch('/api/contact/manual-review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, justification, token }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
resultDiv.textContent = 'Your request for manual review has been submitted. Thank you!';
|
||||
// Hide spam-warning and show the normal form again after a short delay
|
||||
setTimeout(() => {
|
||||
if (spamWarning) spamWarning.style.display = 'none';
|
||||
if (form) {
|
||||
form.style.display = '';
|
||||
form.reset();
|
||||
}
|
||||
resultDiv.textContent = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
resultDiv.textContent = data.error || 'There was an error submitting your manual review request.';
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
@@ -5,7 +5,13 @@ import {
|
||||
checkRateLimit,
|
||||
sendAdminNotification,
|
||||
sendUserConfirmation,
|
||||
sendEmail,
|
||||
} from '../../utils/email-handler';
|
||||
import { isSpamWithGemini } from "../../utils/gemini-spam-check";
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
|
||||
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
|
||||
|
||||
// Enhanced email validation with more comprehensive regex
|
||||
const isValidEmail = (email: string): boolean => {
|
||||
@@ -210,6 +216,23 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
errors.spam = 'Your message was flagged as potential spam. Please revise your message and try again.';
|
||||
}
|
||||
|
||||
// Gemini AI spam detection
|
||||
if (await isSpamWithGemini(message)) {
|
||||
const token = jwt.sign({ email, message }, MANUAL_REVIEW_SECRET, { expiresIn: '1h' });
|
||||
console.warn(
|
||||
`[SPAM DETECTED by Gemini]`,
|
||||
{ name, email, message, ip: request.headers.get('x-forwarded-for') }
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Your message was detected as spam and was not sent. If this is a mistake, you can request a manual review.",
|
||||
spam: true,
|
||||
token
|
||||
}),
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
// If there are validation errors, return them
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return new Response(
|
||||
@@ -283,3 +306,23 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const manualReviewPOST: APIRoute = async ({ request }) => {
|
||||
const { token, email: submittedEmail, justification } = await request.json();
|
||||
try {
|
||||
const payload = jwt.verify(token, MANUAL_REVIEW_SECRET) as { email: string, message: string };
|
||||
if (payload.email !== submittedEmail) {
|
||||
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
|
||||
}
|
||||
// Send to manual review mailbox
|
||||
await sendEmail(
|
||||
MANUAL_REVIEW_EMAIL,
|
||||
'Manual Review Requested: Contact Form Submission',
|
||||
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
|
||||
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
|
||||
);
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
} catch (_err) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
|
||||
}
|
||||
};
|
||||
|
26
src/pages/api/contact/manual-review.ts
Normal file
26
src/pages/api/contact/manual-review.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { sendEmail } from '../../../utils/email-handler';
|
||||
|
||||
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
|
||||
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const { token, email: submittedEmail, justification } = await request.json();
|
||||
try {
|
||||
const payload = jwt.verify(token, MANUAL_REVIEW_SECRET) as { email: string, message: string };
|
||||
if (payload.email !== submittedEmail) {
|
||||
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
|
||||
}
|
||||
// Send to manual review mailbox
|
||||
await sendEmail(
|
||||
MANUAL_REVIEW_EMAIL,
|
||||
'Manual Review Requested: Contact Form Submission',
|
||||
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
|
||||
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
|
||||
);
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
} catch (_err) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
|
||||
}
|
||||
};
|
@@ -54,8 +54,8 @@ function initializeTransporter() {
|
||||
|
||||
// Rate limiter configuration
|
||||
const rateLimiter = new RateLimiterMemory({
|
||||
points: 5, // 5 attempts
|
||||
duration: 3600, // per hour
|
||||
points: 20, // 20 attempts
|
||||
duration: 300, // per 5 minutes
|
||||
});
|
||||
|
||||
// CSRF protection
|
||||
|
15
src/utils/gemini-spam-check.ts
Normal file
15
src/utils/gemini-spam-check.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
if (!GEMINI_API_KEY) {
|
||||
throw new Error("GEMINI_API_KEY environment variable is not set.");
|
||||
}
|
||||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||||
|
||||
export async function isSpamWithGemini(message: string): Promise<boolean> {
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
|
||||
const prompt = `Is the following message spam? Reply with only 'yes' or 'no'.\n\nMessage:\n${message}`;
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = result.response.text().trim().toLowerCase();
|
||||
return response.startsWith("yes");
|
||||
}
|
Reference in New Issue
Block a user