diff --git a/package-lock.json b/package-lock.json index 54b12cc..9136fd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "body-parser": "^2.2.0", "compression": "^1.8.0", + "cookie-parser": "^1.4.7", + "csurf": "^1.11.0", "dotenv": "^16.5.0", "express": "^5.1.0", "express-handlebars": "^8.0.2", @@ -21,6 +23,7 @@ "js-beautify": "^1.15.4", "marked": "^15.0.11", "morgan": "^1.10.0", + "node-fetch": "^3.3.2", "nodemailer": "^7.0.3", "nodemon": "^3.1.10", "path": "^0.12.7", @@ -1130,6 +1133,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -1160,6 +1182,100 @@ "node": ">= 8" } }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", + "license": "MIT", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/culvert": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz", @@ -1636,6 +1752,29 @@ "dev": true, "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1714,6 +1853,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3147,6 +3298,53 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-fetch/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/node-gyp": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", @@ -3928,6 +4126,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4125,6 +4332,12 @@ "node": "*" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4879,6 +5092,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -4948,6 +5170,18 @@ "node": ">=0.8.0" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -5046,6 +5280,15 @@ "foreachasync": "^3.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index d122e5d..9bb5222 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "dependencies": { "body-parser": "^2.2.0", "compression": "^1.8.0", + "cookie-parser": "^1.4.7", + "csurf": "^1.11.0", "dotenv": "^16.5.0", "express": "^5.1.0", "express-handlebars": "^8.0.2", @@ -30,6 +32,7 @@ "js-beautify": "^1.15.4", "marked": "^15.0.11", "morgan": "^1.10.0", + "node-fetch": "^3.3.2", "nodemailer": "^7.0.3", "nodemon": "^3.1.10", "path": "^0.12.7", diff --git a/src/middleware/index.js b/src/middleware/index.js index a0f2de6..50665dc 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -5,6 +5,9 @@ const rateLimit = require("express-rate-limit"); const compression = require("compression"); const helmet = require("helmet"); +const cookieParser = require("cookie-parser"); + +const csrf = require("csurf"); const routes = require("../routes"); const formatHtml = require("./formatHtml"); @@ -38,6 +41,13 @@ err.statusCode = 404; next(err); }); + app.use((err, req, res, next) => { + if (err.code === "EBADCSRFTOKEN") { + res.status(403).send("CSRF token invalid."); + return; + } + next(err); + }); app.use(errorHandler); } diff --git a/src/routes/contact.js b/src/routes/contact.js index 128d3ae..00519d1 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -3,10 +3,19 @@ const router = express.Router(); const sendContactMail = require("../utils/sendContactMail"); const getBaseContext = require("../utils/baseContext"); +const formLimiter = require("../utils/formLimiter"); +const verifyHCaptcha = require("../utils/verifyHCaptcha"); -router.post("/contact", async (req, res, next) => { +router.post("/contact", formLimiter, async (req, res, next) => { try { - const { name, email, message } = req.body; + const { name, email, message, hcaptchaToken } = req.body; + if (!hcaptchaToken) { + return res.status(400).send("Captcha token missing"); + } + const valid = await verifyHCaptcha(hcaptchaToken); + if (!valid) { + return res.status(400).send("Captcha verification failed"); + } await sendContactMail({ name, email, message }); res.redirect("/contact/thankyou"); } catch (err) { @@ -16,6 +25,7 @@ router.get("/contact", async (req, res) => { const context = await getBaseContext({ + csrfToken: res.locals.csrfToken, title: "Contact", }); res.render("pages/contact.handlebars", context); diff --git a/src/routes/index.js b/src/routes/index.js index a98290c..ede4a3c 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -6,6 +6,7 @@ const analytics = require("./analytics"); const robots = require("./robots"); const blog_index = require("./blog_index"); +const csrfToken = require("../utils/csrfToken"); router.post("/track", analytics); @@ -17,7 +18,7 @@ router.use(blog_index); router.use(robots); -router.use(contact); +router.use(contact, csrfToken); router.use(sitemap); router.use(pages); router.use(rssFeed); diff --git a/src/routes/newsletter.js b/src/routes/newsletter.js index 435b8c3..37b2d52 100644 --- a/src/routes/newsletter.js +++ b/src/routes/newsletter.js @@ -2,11 +2,13 @@ const router = express.Router(); const sendNewsletterSubscriptionMail = require("../utils/sendNewsletterSubscriptionMail"); const { saveEmail } = require("../services/newsletterService"); +const formLimiter = require("../utils/formLimiter"); const getBaseContext = require("../utils/baseContext"); router.get("/newsletter", async (req, res) => { const context = await getBaseContext({ + csrfToken: res.locals.csrfToken, title: "Newsletter", }); res.render("pages/newsletter.handlebars", context); @@ -19,7 +21,7 @@ res.render("pages/newsletter-success.handlebars", context); }); -router.post("/newsletter", async (req, res, next) => { +router.post("/newsletter", formLimiter, async (req, res, next) => { const { email } = req.body; if (!email) { return res.status(400).send("Email is required"); diff --git a/src/routes/pages.js b/src/routes/pages.js index 87e4005..ab1080b 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -3,6 +3,7 @@ const router = express.Router(); const ConstructionRoutes = require("../utils/ConstructionRoutes"); const MarkdownRoutes = require("../utils/MarkdownRoutes"); +const csrfToken = require("../utils/csrfToken"); const construction = new ConstructionRoutes(); const markdown = new MarkdownRoutes(); @@ -17,7 +18,7 @@ } const newsletter = require("./newsletter"); -router.use(newsletter); +router.use(newsletter, csrfToken); construction.register("/changelog", "Changelog"); construction.register("/archive", "Archive"); diff --git a/src/services/newsletterService.js b/src/services/newsletterService.js index 400ee0f..abaca37 100644 --- a/src/services/newsletterService.js +++ b/src/services/newsletterService.js @@ -1,45 +1,61 @@ // src/services/newsletterService.js +let writeLock = Promise.resolve(); + const fs = require("fs").promises; const path = require("path"); const filePath = path.join(__dirname, "../../data/newsletter-emails.json"); +// Basic email regex validation +function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + async function saveEmail(email) { try { + if (!isValidEmail(email)) { + throw new Error("Invalid email format"); + } + + // Sanitize input: trim whitespace and lowercase + const sanitizedEmail = email.trim().toLowerCase(); + // Ensure the directory exists await fs.mkdir(path.dirname(filePath), { recursive: true }); - let data = []; - try { - const file = await fs.readFile(filePath, "utf8"); - // Attempt to parse the file content - data = JSON.parse(file); - } catch (e) { - // If file doesn't exist (ENOENT) or contains invalid JSON (SyntaxError), - // we treat it as an empty array and proceed. - // Other errors should still be re-thrown. - if (e.code !== "ENOENT" && !(e instanceof SyntaxError)) { - console.error("Failed to parse newsletter-emails.json:", e); - throw e; - } - // If ENOENT or SyntaxError, 'data' remains an empty array, which is desired. - } - console.log("test"); - - if (!data.includes(email)) { - data.push(email); + writeLock = writeLock.then(async () => { + let data = []; try { - await fs.writeFile(filePath, JSON.stringify(data, null, 2)); - } catch (err) { - console.error("writeFile failed:", err); - throw err; + const file = await fs.readFile(filePath, "utf8"); + // Attempt to parse the file content + data = JSON.parse(file); + } catch (e) { + // If file doesn't exist (ENOENT) or contains invalid JSON (SyntaxError), + // we treat it as an empty array and proceed. + // Other errors should still be re-thrown. + if (e.code !== "ENOENT" && !(e instanceof SyntaxError)) { + console.error("Failed to parse newsletter-emails.json:", e); + throw e; + } + // If ENOENT or SyntaxError, 'data' remains an empty array, which is desired. } - } + + if (!data.includes(sanitizedEmail)) { + data.push(sanitizedEmail); + try { + await fs.writeFile(filePath, JSON.stringify(data, null, 2)); + } catch (err) { + console.error("writeFile failed:", err); + throw err; + } + } + }); } catch (err) { console.error("Failed to save email:", err); throw err; } console.log("test2"); + return await writeLock; } module.exports = { saveEmail }; diff --git a/src/utils/baseContext.js b/src/utils/baseContext.js index d9b41f1..c447b04 100644 --- a/src/utils/baseContext.js +++ b/src/utils/baseContext.js @@ -5,10 +5,12 @@ async function getBaseContext(overrides = {}) { const menu = await getPostsMenu(path.join(__dirname, "../../content/posts")); + console.log(process.env.HCAPTCHA_KEY); return Object.assign( { siteOwner: process.env.SITE_OWNER, originCountry: process.env.COUNTRY, + hCaptchaKey: process.env.HCAPTCHA_KEY, navLinks: [ { href: "/", label: "Home" }, { diff --git a/src/utils/csrfToken.js b/src/utils/csrfToken.js new file mode 100644 index 0000000..b500810 --- /dev/null +++ b/src/utils/csrfToken.js @@ -0,0 +1,13 @@ +// src/csrfToken.js +const router = require("express").Router(); +const cookieParser = require("cookie-parser"); + +const csrf = require("csurf"); + +router.use(cookieParser()); +router.use(csrf({ cookie: true })); +router.use((req, res, next) => { + res.locals.csrfToken = req.csrfToken(); + next(); +}); +module.exports = router; diff --git a/src/utils/formLimiter.js b/src/utils/formLimiter.js new file mode 100644 index 0000000..f28e0d9 --- /dev/null +++ b/src/utils/formLimiter.js @@ -0,0 +1,8 @@ +const rateLimit = require("express-rate-limit"); + +const formLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 5, // max 5 requests per window per IP + message: "Too many requests, please try again later.", +}); +module.exports = formLimiter; diff --git a/src/utils/sendContactMail.js b/src/utils/sendContactMail.js index 551ef68..0fca3f0 100644 --- a/src/utils/sendContactMail.js +++ b/src/utils/sendContactMail.js @@ -1,16 +1,44 @@ // src/utils/sendContactMail.js const transporter = require("./transporter"); +// Basic sanitization and validation functions +function sanitizeInput(input) { + return String(input) + .replace(/[\r\n<>]/g, "") + .trim(); +} + +function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + function sendContactMail({ name, email, subject, message }) { const { DOMAIN: domain } = process.env; + + // Sanitize inputs + const cleanName = sanitizeInput(name); + const cleanEmail = sanitizeInput(email); + const cleanSubject = sanitizeInput(subject || "New Contact Form Submission"); + const cleanMessage = sanitizeInput(message); + + // Validate email + if (!isValidEmail(cleanEmail)) { + throw new Error("Invalid email format"); + } + const data = { from: `"Contact Form" `, to: process.env.MAIL_USER, - replyTo: `"${name}" <${email}>`, - subject: subject || "New Contact Form Submission", - text: message, + replyTo: `"${cleanName}" <${cleanEmail}>`, + subject: cleanSubject, + text: cleanMessage, }; - console.log(data); + + // Optional: limit message length to prevent abuse + if (cleanMessage.length > 2000) { + throw new Error("Message too long"); + } + return transporter.sendMail(data); } diff --git a/src/utils/verifyHCaptcha.js b/src/utils/verifyHCaptcha.js new file mode 100644 index 0000000..a1d0d10 --- /dev/null +++ b/src/utils/verifyHCaptcha.js @@ -0,0 +1,13 @@ +const fetch = require("node-fetch"); + +async function verifyHCaptcha(token) { + const secret = process.env.HCAPTCHA_SECRET; // Your hCaptcha secret key + const response = await fetch("https://hcaptcha.com/siteverify", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ secret, response: token }), + }); + const data = await response.json(); + return data.success === true; +} +module.exports = verifyHCaptcha; diff --git a/src/views/pages/contact.handlebars b/src/views/pages/contact.handlebars index 3bdccb1..4b9dc47 100644 --- a/src/views/pages/contact.handlebars +++ b/src/views/pages/contact.handlebars @@ -1,9 +1,13 @@ {{#section "styles"}} {{/section}} +{{#section "scripts"}} + +{{/section}}

{{title}}

+
@@ -19,9 +23,10 @@
- +
- +

* Required field

diff --git a/src/views/pages/newsletter.handlebars b/src/views/pages/newsletter.handlebars index e39b170..59470c0 100644 --- a/src/views/pages/newsletter.handlebars +++ b/src/views/pages/newsletter.handlebars @@ -1,15 +1,19 @@ {{#section "styles"}} {{/section}} - +{{#section "scripts"}} + +{{/section}}

{{title}}

* Required field

diff --git a/yarn.lock b/yarn.lock index e686bd9..baa2096 100644 --- a/yarn.lock +++ b/yarn.lock @@ -540,16 +540,34 @@ resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== +cookie-parser@^1.4.7: + version "1.4.7" + resolved "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz" + integrity sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw== + dependencies: + cookie "0.7.2" + cookie-signature "1.0.6" + cookie-signature@^1.2.1: version "1.2.2" resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz" integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -cookie@^0.7.1: +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@^0.7.1, cookie@0.7.2: version "0.7.2" resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +cookie@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz" + integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== + croner@~4.1.92: version "4.1.97" resolved "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz" @@ -564,11 +582,35 @@ shebang-command "^2.0.0" which "^2.0.1" +csrf@3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz" + integrity sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w== + dependencies: + rndm "1.2.0" + tsscmp "1.0.6" + uid-safe "2.1.5" + +csurf@^1.11.0: + version "1.11.0" + resolved "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz" + integrity sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ== + dependencies: + cookie "0.4.0" + cookie-signature "1.0.6" + csrf "3.1.0" + http-errors "~1.7.3" + culvert@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz" integrity sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg== +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + data-uri-to-buffer@^6.0.2: version "6.0.2" resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz" @@ -643,6 +685,11 @@ resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + detect-libc@^2.0.0: version "2.0.4" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz" @@ -872,6 +919,14 @@ resolved "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz" integrity sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw== +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz" @@ -914,6 +969,13 @@ cross-spawn "^7.0.6" signal-exit "^4.0.1" +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" @@ -1163,6 +1225,17 @@ statuses "2.0.1" toidentifier "1.0.1" +http-errors@~1.7.3: + version "1.7.3" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz" + integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz" @@ -1726,6 +1799,20 @@ resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + node-gyp@8.x: version "8.4.1" resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz" @@ -2138,6 +2225,11 @@ dependencies: side-channel "^1.1.0" +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz" + integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ== + range-parser@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz" @@ -2223,6 +2315,11 @@ dependencies: glob "^7.1.3" +rndm@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz" + integrity sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw== + router@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/router/-/router-2.2.0.tgz" @@ -2326,6 +2423,11 @@ resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +setprototypeof@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz" + integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" @@ -2512,6 +2614,11 @@ resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +"statuses@>= 1.5.0 < 2": + version "1.5.0" + resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" @@ -2659,6 +2766,11 @@ dependencies: is-number "^7.0.0" +toidentifier@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz" + integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" @@ -2679,6 +2791,11 @@ resolved "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz" @@ -2712,6 +2829,13 @@ resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz" integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== +uid-safe@2.1.5: + version "2.1.5" + resolved "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + undefsafe@^2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz" @@ -2770,6 +2894,11 @@ dependencies: foreachasync "^3.0.0" +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + which@^2.0.1, which@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"