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:
138
package-lock.json
generated
138
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@astrolib/analytics": "^0.6.1",
|
||||
"@astrolib/seo": "^1.0.0-beta.8",
|
||||
"@fontsource-variable/inter": "^5.1.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -24,6 +25,7 @@
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"form-data": "^4.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"limax": "4.1.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"luxon": "^3.6.1",
|
||||
@@ -47,6 +49,7 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
@@ -1536,6 +1539,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/generative-ai": {
|
||||
"version": "0.24.1",
|
||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
|
||||
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -2907,6 +2919,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
|
||||
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz",
|
||||
@@ -4034,6 +4057,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -4981,6 +5010,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -7026,6 +7064,49 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7441,11 +7522,40 @@
|
||||
"integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -7454,6 +7564,12 @@
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -10288,6 +10404,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
|
@@ -29,6 +29,7 @@
|
||||
"@astrolib/analytics": "^0.6.1",
|
||||
"@astrolib/seo": "^1.0.0-beta.8",
|
||||
"@fontsource-variable/inter": "^5.1.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -38,6 +39,7 @@
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"form-data": "^4.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"limax": "4.1.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"luxon": "^3.6.1",
|
||||
@@ -61,6 +63,7 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
|
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