diff --git a/package-lock.json b/package-lock.json index 9d2c786..2b54ef3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 37451cd..9e78bd9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ContactForm.astro b/src/components/ContactForm.astro new file mode 100644 index 0000000..0108c6a --- /dev/null +++ b/src/components/ContactForm.astro @@ -0,0 +1,84 @@ +
+ + + + \ No newline at end of file diff --git a/src/components/ui/Form.astro b/src/components/ui/Form.astro index cfff621..c0b1339 100644 --- a/src/components/ui/Form.astro +++ b/src/components/ui/Form.astro @@ -128,6 +128,23 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } } + + + diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 212bed2..3dfabf7 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -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', + `Email: ${submittedEmail}
Message: ${payload.message}
Justification: ${justification || 'None provided'}
`, + `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 }); + } +}; diff --git a/src/pages/api/contact/manual-review.ts b/src/pages/api/contact/manual-review.ts new file mode 100644 index 0000000..7988b87 --- /dev/null +++ b/src/pages/api/contact/manual-review.ts @@ -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', + `Email: ${submittedEmail}
Message: ${payload.message}
Justification: ${justification || 'None provided'}
`, + `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 }); + } +}; \ No newline at end of file diff --git a/src/utils/email-handler.ts b/src/utils/email-handler.ts index 0d93c01..7137ca8 100644 --- a/src/utils/email-handler.ts +++ b/src/utils/email-handler.ts @@ -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 diff --git a/src/utils/gemini-spam-check.ts b/src/utils/gemini-spam-check.ts new file mode 100644 index 0000000..6213534 --- /dev/null +++ b/src/utils/gemini-spam-check.ts @@ -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