diff --git a/.githooks/post-receive b/.githooks/post-receive
index 256f6dd..757faa0 100644
--- a/.githooks/post-receive
+++ b/.githooks/post-receive
@@ -194,6 +194,7 @@
initialize_submodules "$tmpdir" || return 1
export TEST_PORT=4123
+ export TEST_SCHEMA=http
export NODE_ENV=testing
echo "Running build scripts..."
@@ -212,7 +213,7 @@
run_tests "$branch" "$pidfile" "$logfile" || return 1
kill "$(cat "$pidfile")" 2>/dev/null || true
- unset TEST_PORT NODE_ENV
+ unset TEST_PORT TEST_SCHEMA NODE_ENV
echo "Tests passed for branch '$branch' in temporary environment."
return 0
diff --git a/content b/content
index 938e7e7..ab062f8 160000
--- a/content
+++ b/content
@@ -1 +1 @@
-Subproject commit 938e7e76247b5c34c754f7be344ce5f7b881e1f3
+Subproject commit ab062f8e24672c3b225a14bafbd806aff679e1d5
diff --git a/data/newsletter-emails.json b/data/newsletter-emails.json
index 5b06ffc..5c28c07 100644
--- a/data/newsletter-emails.json
+++ b/data/newsletter-emails.json
@@ -1,4 +1,6 @@
[
"test@email.com",
- "tete@hth3.ce"
+ "tete@hth3.ce",
+ "test@jasonpoage.com",
+ "test@hello.com"
]
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 0a99081..dee6d7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,6 @@
"": {
"name": "express-blog",
"version": "1.0.0",
- "hasInstallScript": true,
"license": "ISC",
"dependencies": {
"better-sqlite3": "^12.2.0",
@@ -37,6 +36,7 @@
"sharp": "^0.34.3",
"sqlite3": "^5.1.7",
"to-ico": "^1.1.5",
+ "validator": "^13.15.15",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"xss": "^1.0.15",
@@ -8016,6 +8016,15 @@
"uuid": "bin/uuid"
}
},
+ "node_modules/validator": {
+ "version": "13.15.15",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
+ "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
diff --git a/package.json b/package.json
index fb725b5..b4682d0 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"sharp": "^0.34.3",
"sqlite3": "^5.1.7",
"to-ico": "^1.1.5",
+ "validator": "^13.15.15",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"xss": "^1.0.15",
diff --git a/public/css/logs.css b/public/css/logs.css
index a03a615..0987ceb 100644
--- a/public/css/logs.css
+++ b/public/css/logs.css
@@ -73,3 +73,6 @@
color: #666;
font-style: italic;
}
+.pagination-controls input[type="number"] {
+ width: 60px;
+}
diff --git a/public/css/post.css b/public/css/post.css
index 8d4054d..fcf6b54 100644
--- a/public/css/post.css
+++ b/public/css/post.css
@@ -2,9 +2,20 @@
margin-top: 1.5rem;
}
.markdown-content p, .markdown-content li {
- line-height: 1.6;
- letter-spacing: 0.02em;
+ max-width: 70ch;
+ line-height: 1.8;
+ margin-bottom: 1.75rem;
+ padding-left: 0.5rem;
+ text-indent: 0.5rem;
+ color: #2c3e50; /* darker, softer tone */
+ font-weight: 400;
+ font-size: 1.05rem;
+ letter-spacing: 0.03em;
+ transition: color 0.3s ease;
}
+ .markdown-content p:hover {
+ color: #1a242f;
+}
.markdown-content p,
.markdown-content li {
max-width: 70ch;
@@ -73,3 +84,53 @@
margin-bottom: 0.75rem;
text-indent: -0.25rem; /* slight hanging indent */
}
+.markdown-content h3,
+.markdown-content h4,
+.markdown-content h5,
+.markdown-content h6 {
+ margin-top: 1.5rem;
+ margin-bottom: 1rem;
+ font-weight: 600;
+ padding-left: 0.5rem;
+ border-left: 3px solid #bbb;
+ margin-left: -0.5rem;
+ color: #444;
+}
+
+.markdown-content ol {
+ padding-left: 1.5rem;
+ list-style-type: decimal;
+ margin-bottom: 1.25rem;
+}
+
+.markdown-content ol li {
+ margin-bottom: 0.75rem;
+ text-indent: -0.25rem;
+}
+
+.markdown-content hr {
+ border: none;
+ border-top: 1px solid #ddd;
+ margin: 2rem 0;
+}
+
+.markdown-content strong {
+ font-weight: 700;
+ color: #222;
+}
+
+.markdown-content em {
+ font-style: italic;
+ color: #555;
+}
+
+.markdown-content code {
+ background-color: #f0f0f0;
+ color: #c7254e;
+}
+
+.markdown-content pre code {
+ background-color: transparent;
+ color: inherit;
+ padding: 0;
+}
diff --git a/public/js/logs.js b/public/js/logs.js
index 07cb673..f6408d7 100644
--- a/public/js/logs.js
+++ b/public/js/logs.js
@@ -46,12 +46,12 @@
-
+
Loading logs...
`;
@@ -297,14 +297,14 @@
showLoading() {
const indicator = document.getElementById("loadingIndicator");
if (indicator) {
- indicator.style.display = "block";
+ indicator.classList.remove("hide");
}
}
hideLoading() {
const indicator = document.getElementById("loadingIndicator");
if (indicator) {
- indicator.style.display = "none";
+ indicator.classList.add("hide");
}
}
}
diff --git a/src/css/base.css b/src/css/base.css
index 8b25fe3..02f9e54 100644
--- a/src/css/base.css
+++ b/src/css/base.css
@@ -33,3 +33,9 @@
position: relative;
z-index: 1;
}
+.hide {
+ display: none;
+}
+.inline {
+ display: inline
+}
diff --git a/src/css/footer.css b/src/css/footer.css
index 8ac3094..d1f42f7 100644
--- a/src/css/footer.css
+++ b/src/css/footer.css
@@ -91,3 +91,10 @@
font-size: 0.85em;
color: #777;
}
+
+footer small {
+ color: #666; font-size: 10px; opacity: 0.3;
+}
+footer small a {
+ color: inherit
+}
diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js
index 8ad85cb..1dc9098 100644
--- a/src/middleware/errorHandler.js
+++ b/src/middleware/errorHandler.js
@@ -46,7 +46,9 @@
const errorContext = getErrorContext(code || statusCode);
if (!isDev) {
- res.redirect(`${ERROR_REDIRECT_PATH}?code=${errorContext.statusCode}`);
+ res.customRedirect(
+ `${ERROR_REDIRECT_PATH}?code=${errorContext.statusCode}`
+ );
return;
}
diff --git a/src/middleware/redirect.js b/src/middleware/redirect.js
index da79b8d..a82d6d0 100644
--- a/src/middleware/redirect.js
+++ b/src/middleware/redirect.js
@@ -24,7 +24,7 @@
}
// Generic redirect handler
-function handleRedirect(req, res, targetPath) {
+function handleRedirect(req, res, targetPath, status = 302) {
const redirectUrl = buildRedirectUrl(req, targetPath);
// Log the redirect for debugging
@@ -50,10 +50,12 @@
// Middleware function to check for redirects
function redirectMiddleware(req, res, next) {
+ res.customRedirect = (targetPath, status) =>
+ handleRedirect(req, res, targetPath, status);
const targetPath = redirectConfig[req.path];
if (targetPath) {
- return handleRedirect(req, res, targetPath);
+ return handleRedirect(req, res, targetPath, 301);
}
// No redirect needed, continue to next middleware
diff --git a/src/routes/admin.js b/src/routes/admin.js
index ed025fc..2c4dd97 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -37,10 +37,7 @@
const adminLoginUrl = `${process.env.AUTH_LOGIN}${rd}`;
res.set("Content-Type", "text/html");
- res
- .status(301)
- .set("Location", adminLoginUrl)
- .render("pages/redirect", { layout: "redirect", adminLoginUrl });
+ res.customRedirect(adminLoginUrl, 301);
});
module.exports = router;
diff --git a/src/routes/contact.js b/src/routes/contact.js
index c668f6c..d60d786 100644
--- a/src/routes/contact.js
+++ b/src/routes/contact.js
@@ -47,213 +47,20 @@
const formLimiter = require("../utils/formLimiter");
const verifyHCaptcha = require("../utils/verifyHCaptcha");
const crypto = require("crypto");
-const fs = require("fs").promises;
-const path = require("path");
const HttpError = require("../utils/HttpError");
-const { baseUrl } = require("../utils/baseUrl");
const { qualifyLink } = require("../utils/qualifyLinks");
+const {
+ captureSecurityData,
+ analyzeThreatLevel,
+ logSecurityEvent,
+} = require("../utils/securityForensics");
-const { winstonLogger } = require("../utils/logging");
-
-// Threat detection patterns
-const THREAT_PATTERNS = {
- // Common phishing/spam indicators
- suspiciousKeywords: [
- "verify account",
- "urgent action",
- "suspended account",
- "click here",
- "limited time",
- "act now",
- "confirm identity",
- "update payment",
- "security alert",
- "unusual activity",
- ],
-
- // Suspicious domains (add known bad actor domains)
- suspiciousDomains: [
- "tempmail.org",
- "10minutemail.com",
- "guerrillamail.com",
- "throwaway.email",
- "temp-mail.org",
- ],
-
- // Suspicious patterns
- suspiciousPatterns: [
- /https?:\/\/[^\s]+/gi, // URLs in messages
- /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, // Email addresses
- /\b(?:\d{4}[-\s]?){3}\d{4}\b/g, // Credit card patterns
- /\b\d{3}-\d{2}-\d{4}\b/g, // SSN patterns
- ],
-};
-
-// Enhanced forensic data collection (focused on threat detection)
-function captureSecurityData(req, additionalData = {}) {
- const timestamp = new Date().toISOString();
- const requestId = crypto.randomUUID();
-
- // Connection and network data
- const connectionData = {
- ip: req.ip,
- ips: req.ips || [],
- remoteAddress: req.socket?.remoteAddress,
- protocol: req.protocol,
- secure: req.secure,
- hostname: req.hostname,
- originalUrl: req.originalUrl,
- encrypted: req.socket?.encrypted || false,
- };
-
- // Security-relevant headers
- const securityHeaders = {
- userAgent: req.headers["user-agent"],
- acceptLanguage: req.headers["accept-language"],
- referer: req.headers["referer"],
- origin: req.headers["origin"],
- xForwardedFor: req.headers["x-forwarded-for"],
- xRealIp: req.headers["x-real-ip"],
- host: req.headers["host"],
- // Check for proxy/VPN indicators
- via: req.headers["via"],
- xForwardedProto: req.headers["x-forwarded-proto"],
- cfConnectingIp: req.headers["cf-connecting-ip"], // Cloudflare
- cfIpCountry: req.headers["cf-ipcountry"],
- cfRay: req.headers["cf-ray"],
- };
-
- // Request timing and patterns
- const requestData = {
- method: req.method,
- url: req.url,
- path: req.path,
- query: req.query,
- timestamp: timestamp,
- requestStart: req._startTime || Date.now(),
- processingTime: Date.now() - (req._startTime || Date.now()),
- };
-
- // TLS/Security info
- let tlsData = null;
- if (req.socket && req.socket.encrypted) {
- try {
- const cipher = req.socket.getCipher ? req.socket.getCipher() : null;
- tlsData = {
- cipher: cipher,
- tlsVersion: req.socket.getProtocol ? req.socket.getProtocol() : null,
- authorized: req.socket.authorized,
- };
- } catch (err) {
- tlsData = { error: "TLS data unavailable" };
- }
- }
-
- return {
- requestId,
- timestamp,
- connection: connectionData,
- security: securityHeaders,
- request: requestData,
- tls: tlsData,
- additional: additionalData,
- };
+function isReasonableLength(str, maxLen) {
+ return (
+ typeof str === "string" && str.trim().length > 0 && str.length <= maxLen
+ );
}
-// Threat analysis function
-function analyzeThreatLevel(formData, securityData) {
- let threatScore = 0;
- const indicators = [];
-
- // Check message content for suspicious patterns
- const message = formData.message?.toLowerCase() || "";
- const email = formData.email?.toLowerCase() || "";
- const name = formData.name?.toLowerCase() || "";
-
- // Suspicious keywords in message
- THREAT_PATTERNS.suspiciousKeywords.forEach((keyword) => {
- if (message.includes(keyword.toLowerCase())) {
- threatScore += 3;
- indicators.push(`suspicious_keyword: ${keyword}`);
- }
- });
-
- // Check for suspicious email domains
- const emailDomain = email.split("@")[1];
- if (emailDomain && THREAT_PATTERNS.suspiciousDomains.includes(emailDomain)) {
- threatScore += 5;
- indicators.push(`suspicious_email_domain: ${emailDomain}`);
- }
-
- // Check for suspicious patterns in content
- THREAT_PATTERNS.suspiciousPatterns.forEach((pattern, index) => {
- if (pattern.test(message)) {
- threatScore += 2;
- indicators.push(`suspicious_pattern_${index}`);
- }
- });
-
- // Check for rapid form submission (potential automation)
- if (securityData.additional.clientData?.formTime < 5000) {
- // Less than 5 seconds
- threatScore += 2;
- indicators.push("rapid_submission");
- }
-
- // Check for suspicious user agent
- const userAgent = securityData.security.userAgent || "";
- if (!userAgent || userAgent.includes("bot") || userAgent.includes("crawl")) {
- threatScore += 3;
- indicators.push("suspicious_user_agent");
- }
-
- // Check for missing referer (direct access)
- if (!securityData.security.referer) {
- threatScore += 1;
- indicators.push("no_referer");
- }
-
- // Determine threat level
- let threatLevel = "low";
- if (threatScore >= 8) threatLevel = "high";
- else if (threatScore >= 4) threatLevel = "medium";
-
- return {
- score: threatScore,
- level: threatLevel,
- indicators: indicators,
- requiresReview: threatScore >= 4,
- };
-}
-
-async function logSecurityEvent(data, eventType = "contact_submission") {
- try {
- const date = new Date().toISOString().split("T")[0];
- const logEntry = {
- eventType,
- ...data,
- loggedAt: new Date().toISOString(),
- };
-
- // Log security event at custom 'security' level
- winstonLogger.log("security", logEntry);
-
- // Separate high-threat log file
- if (data.threatAnalysis?.level === "high") {
- const logDir = path.join(__dirname, "..", "..", "logs", "security");
- await fs.mkdir(logDir, { recursive: true });
-
- const alertFile = path.join(logDir, `high_threat_${date}.log`);
- await fs.appendFile(alertFile, message + "\n");
- }
- } catch (err) {
- // Fail silently or log to error log, depending on requirements
- winstonLogger.error(`Failed to log security event: ${err.message}`);
- }
-}
-
-module.exports = { logSecurityEvent };
-
// Middleware to capture request start time
router.use((req, res, next) => {
req._startTime = Date.now();
@@ -264,18 +71,11 @@
try {
const { name, email, message, subject, hcaptchaToken, clientData } =
req.body;
- // Basic input validation
- function isValidEmail(email) {
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
- }
- function isReasonableLength(str, maxLen) {
- return (
- typeof str === "string" && str.trim().length > 0 && str.length <= maxLen
- );
- }
+ const emailResult = validateAndSanitizeEmail(email);
if (
+ !emailResult.valid ||
!isReasonableLength(name, 100) ||
!isValidEmail(email) ||
!isReasonableLength(subject, 150) ||
@@ -356,7 +156,7 @@
);
// Still redirect to thank you to not reveal detection
- res.redirect("/contact/thankyou");
+ res.customRedirect("/contact/thankyou");
return;
}
@@ -379,7 +179,7 @@
"contact_success"
);
- res.redirect("/contact/thankyou");
+ res.customRedirect("/contact/thankyou");
} catch (err) {
const errorData = captureSecurityData(req, {
error: {
diff --git a/src/routes/index.js b/src/routes/index.js
index 54b0cc6..b873373 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -119,8 +119,8 @@
router.get("/", (req, res) => {
console.log(qualifyLink("/blog"));
- // res.redirect(301, qualifyLink("/blog"));
- res.redirect(301, "/blog");
+ // res.customRedirect(qualifyLink("/blog"), 301);
+ res.customRedirect("/blog", 301);
});
router.use((req, res, next) => {
diff --git a/src/routes/newsletter.js b/src/routes/newsletter.js
index 70f2156..58ff4f9 100644
--- a/src/routes/newsletter.js
+++ b/src/routes/newsletter.js
@@ -4,56 +4,46 @@
const { saveEmail } = require("../services/newsletterService");
const formLimiter = require("../utils/formLimiter");
+const { validateAndSanitizeEmail } = require("../utils/emailValidator");
+
const { qualifyLink } = require("../utils/qualifyLinks");
const HttpError = require("../utils/HttpError");
-const { Http } = require("winston/lib/winston/transports");
router.get("/newsletter", async (req, res) => {
const context = {
csrfToken: res.locals.csrfToken,
title: "Newsletter",
formAction: qualifyLink("/newsletter"),
- formMethod: "POST"
- }
+ formMethod: "POST",
+ };
res.renderWithBaseContext("pages/newsletter.handlebars", context);
});
router.get("/newsletter/success", async (req, res) => {
const context = {
title: "Thank You",
- }
+ };
res.renderWithBaseContext("pages/newsletter-success.handlebars", context);
});
router.post("/newsletter", formLimiter, async (req, res, next) => {
- const { email } = req.body;
- // Basic validation
- if (!email || typeof email !== 'string') {
- return next(new HttpError("Email is required", 400))
- }
- // Sanitize and validate email
- const sanitizedEmail = validator.normalizeEmail(email.trim());
- if (!sanitizedEmail || !validator.isEmail(sanitizedEmail)) {
- return next(new HttpError("Invalid email format", 400))
- }
-
- // Length check
- if (sanitizedEmail.length > 320) { // RFC 5321 limit
- return next(new HttpError("Email too long", 400))
+ const { email: rawEmail } = req.body;
+ const result = validateAndSanitizeEmail(rawEmail);
+
+ if (!result.valid) {
+ return next(new HttpError(result.message, 400));
}
+ const sanitizedEmail = result.email;
+
try {
- await saveEmail(email);
- await sendNewsletterSubscriptionMail({ email });
- res.redirect("/newsletter/success");
+ await saveEmail(sanitizedEmail);
+ await sendNewsletterSubscriptionMail({ email: sanitizedEmail });
+ res.customRedirect("/newsletter/success"); // fixme qualifyLink()
} catch (err) {
- // Log the error but don't expose internal details
- console.error('Newsletter subscription error:', err);
-
- // Generic response to avoid information disclosure
- if (err.code === 'DUPLICATE_EMAIL') {
- // Still redirect to success to avoid enumeration
- return res.redirect("/newsletter/success");
+ console.error("Newsletter subscription error:", err);
+ if (err.code === "DUPLICATE_EMAIL") {
+ return res.customRedirect("/newsletter/success"); // fixme qualifyLink()
}
next(err);
}
diff --git a/src/services/sitemapService.js b/src/services/sitemapService.js
index 14422cc..c380972 100644
--- a/src/services/sitemapService.js
+++ b/src/services/sitemapService.js
@@ -40,24 +40,43 @@
}));
}
+ injectPlaceholder(tree, key, items) {
+ for (const node of tree) {
+ if (Array.isArray(node.children)) {
+ const index = node.children.findIndex(
+ (child) => child.loc === `#inject:${key}`
+ );
+
+ if (index !== -1) {
+ const placeholder = node.children[index];
+ node.children.splice(index, 1, ...items);
+ return true;
+ }
+
+ if (this.injectPlaceholder(node.children, key, items)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
async getCompleteSitemap() {
const [staticPages, blogUrls] = await Promise.all([
this.getStaticPages(),
this.getBlogPostUrls(),
]);
- const blogSection = {
- title: "Blog Posts",
- children: blogUrls.map((url) => ({
- loc: url.loc,
- title: url.loc.split("/").pop().replace(/-/g, " "),
- lastmod: url.lastmod,
- changefreq: url.changefreq,
- priority: url.priority,
- })),
- };
-
- return [...staticPages, blogSection];
+ const blogPosts = blogUrls.map((url) => ({
+ loc: url.loc,
+ title: url.loc.split("/").pop().replace(/-/g, " "),
+ lastmod: url.lastmod,
+ changefreq: url.changefreq,
+ priority: url.priority,
+ }));
+ this.injectPlaceholder(staticPages, "blog-posts", blogPosts);
+ // return [...staticPages, blogSection];
+ return staticPages;
}
async getAllUrls() {
diff --git a/src/utils/baseUrl.js b/src/utils/baseUrl.js
index d5c2769..dea4d02 100644
--- a/src/utils/baseUrl.js
+++ b/src/utils/baseUrl.js
@@ -1,7 +1,7 @@
// src/utils/baseUrl.js
function getBaseUrl({ schema = null, host = null } = {}) {
- const envSchema = process.env.SERVER_SCHEMA;
- const envDomain = process.env.SERVER_DOMAIN;
+ const envSchema = process.env.TEST_SCHEMA || process.env.SERVER_SCHEMA;
+ const envDomain = process.env.TEST_DOMAIN || process.env.SERVER_DOMAIN;
const finalProtocol = envSchema || schema || "https";
const finalDomain = (envDomain || host || "localhost")
diff --git a/src/utils/emailValidator.js b/src/utils/emailValidator.js
new file mode 100644
index 0000000..bf087b3
--- /dev/null
+++ b/src/utils/emailValidator.js
@@ -0,0 +1,36 @@
+const validator = require("validator");
+
+const MAX_EMAIL_LENGTH = 320; // RFC 5321 limit
+const MESSAGES = {
+ REQUIRED: "Email is required",
+ INVALID: "Invalid email format",
+ TOO_LONG: "Email too long",
+};
+
+function validateAndSanitizeEmail(rawEmail) {
+ if (!rawEmail || typeof rawEmail !== "string") {
+ return { valid: false, message: MESSAGES.REQUIRED };
+ }
+
+ const email = validator.normalizeEmail(rawEmail.trim().toLowerCase());
+
+ if (!email || !validator.isEmail(email)) {
+ return { valid: false, message: MESSAGES.INVALID };
+ }
+
+ if (email.length > MAX_EMAIL_LENGTH) {
+ return { valid: false, message: MESSAGES.TOO_LONG };
+ }
+
+ if (email.includes("..") || email.startsWith(".") || email.endsWith(".")) {
+ return { valid: false, message: MESSAGES.INVALID };
+ }
+
+ return { valid: true, email };
+}
+
+module.exports = {
+ validateAndSanitizeEmail,
+ MESSAGES,
+ MAX_EMAIL_LENGTH,
+};
diff --git a/src/utils/securityForensics.js b/src/utils/securityForensics.js
new file mode 100644
index 0000000..27db36d
--- /dev/null
+++ b/src/utils/securityForensics.js
@@ -0,0 +1,210 @@
+// // src/utils/securityForensics.js
+const crypto = require("crypto");
+const fs = require("fs").promises;
+const path = require("path");
+const HttpError = require("../utils/HttpError");
+
+const { winstonLogger } = require("../utils/logging");
+
+// Threat detection patterns
+const THREAT_PATTERNS = {
+ // Common phishing/spam indicators
+ suspiciousKeywords: [
+ "verify account",
+ "urgent action",
+ "suspended account",
+ "click here",
+ "limited time",
+ "act now",
+ "confirm identity",
+ "update payment",
+ "security alert",
+ "unusual activity",
+ ],
+
+ // Suspicious domains (add known bad actor domains)
+ suspiciousDomains: [
+ "tempmail.org",
+ "10minutemail.com",
+ "guerrillamail.com",
+ "throwaway.email",
+ "temp-mail.org",
+ ],
+
+ // Suspicious patterns
+ suspiciousPatterns: [
+ /https?:\/\/[^\s]+/gi, // URLs in messages
+ /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, // Email addresses
+ /\b(?:\d{4}[-\s]?){3}\d{4}\b/g, // Credit card patterns
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN patterns
+ ],
+};
+
+// Enhanced forensic data collection (focused on threat detection)
+function captureSecurityData(req, additionalData = {}) {
+ const timestamp = new Date().toISOString();
+ const requestId = crypto.randomUUID();
+
+ // Connection and network data
+ const connectionData = {
+ ip: req.ip,
+ ips: req.ips || [],
+ remoteAddress: req.socket?.remoteAddress,
+ protocol: req.protocol,
+ secure: req.secure,
+ hostname: req.hostname,
+ originalUrl: req.originalUrl,
+ encrypted: req.socket?.encrypted || false,
+ };
+
+ // Security-relevant headers
+ const securityHeaders = {
+ userAgent: req.headers["user-agent"],
+ acceptLanguage: req.headers["accept-language"],
+ referer: req.headers["referer"],
+ origin: req.headers["origin"],
+ xForwardedFor: req.headers["x-forwarded-for"],
+ xRealIp: req.headers["x-real-ip"],
+ host: req.headers["host"],
+ // Check for proxy/VPN indicators
+ via: req.headers["via"],
+ xForwardedProto: req.headers["x-forwarded-proto"],
+ cfConnectingIp: req.headers["cf-connecting-ip"], // Cloudflare
+ cfIpCountry: req.headers["cf-ipcountry"],
+ cfRay: req.headers["cf-ray"],
+ };
+
+ // Request timing and patterns
+ const requestData = {
+ method: req.method,
+ url: req.url,
+ path: req.path,
+ query: req.query,
+ timestamp: timestamp,
+ requestStart: req._startTime || Date.now(),
+ processingTime: Date.now() - (req._startTime || Date.now()),
+ };
+
+ // TLS/Security info
+ let tlsData = null;
+ if (req.socket && req.socket.encrypted) {
+ try {
+ const cipher = req.socket.getCipher ? req.socket.getCipher() : null;
+ tlsData = {
+ cipher: cipher,
+ tlsVersion: req.socket.getProtocol ? req.socket.getProtocol() : null,
+ authorized: req.socket.authorized,
+ };
+ } catch (err) {
+ tlsData = { error: "TLS data unavailable" };
+ }
+ }
+
+ return {
+ requestId,
+ timestamp,
+ connection: connectionData,
+ security: securityHeaders,
+ request: requestData,
+ tls: tlsData,
+ additional: additionalData,
+ };
+}
+
+// Threat analysis function
+function analyzeThreatLevel(formData, securityData) {
+ let threatScore = 0;
+ const indicators = [];
+
+ // Check message content for suspicious patterns
+ const message = formData.message?.toLowerCase() || "";
+ const name = formData.name?.toLowerCase() || "";
+
+ // Suspicious keywords in message
+ THREAT_PATTERNS.suspiciousKeywords.forEach((keyword) => {
+ if (message.includes(keyword.toLowerCase())) {
+ threatScore += 3;
+ indicators.push(`suspicious_keyword: ${keyword}`);
+ }
+ });
+
+ const email = formData.email?.toLowerCase() || "";
+ // Check for suspicious email domains
+ const emailDomain = email.split("@")[1];
+ if (emailDomain && THREAT_PATTERNS.suspiciousDomains.includes(emailDomain)) {
+ threatScore += 5;
+ indicators.push(`suspicious_email_domain: ${emailDomain}`);
+ }
+
+ // Check for suspicious patterns in content
+ THREAT_PATTERNS.suspiciousPatterns.forEach((pattern, index) => {
+ if (pattern.test(message)) {
+ threatScore += 2;
+ indicators.push(`suspicious_pattern_${index}`);
+ }
+ });
+
+ // Check for rapid form submission (potential automation)
+ if (securityData.additional.clientData?.formTime < 5000) {
+ // Less than 5 seconds
+ threatScore += 2;
+ indicators.push("rapid_submission");
+ }
+
+ // Check for suspicious user agent
+ const userAgent = securityData.security.userAgent || "";
+ if (!userAgent || userAgent.includes("bot") || userAgent.includes("crawl")) {
+ threatScore += 3;
+ indicators.push("suspicious_user_agent");
+ }
+
+ // Check for missing referer (direct access)
+ if (!securityData.security.referer) {
+ threatScore += 1;
+ indicators.push("no_referer");
+ }
+
+ // Determine threat level
+ let threatLevel = "low";
+ if (threatScore >= 8) threatLevel = "high";
+ else if (threatScore >= 4) threatLevel = "medium";
+
+ return {
+ score: threatScore,
+ level: threatLevel,
+ indicators: indicators,
+ requiresReview: threatScore >= 4,
+ };
+}
+
+async function logSecurityEvent(data, eventType = "contact_submission") {
+ try {
+ const date = new Date().toISOString().split("T")[0];
+ const logEntry = {
+ eventType,
+ ...data,
+ loggedAt: new Date().toISOString(),
+ };
+
+ // Log security event at custom 'security' level
+ winstonLogger.log("security", logEntry);
+
+ // Separate high-threat log file
+ if (data.threatAnalysis?.level === "high") {
+ const logDir = path.join(__dirname, "..", "..", "logs", "security");
+ await fs.mkdir(logDir, { recursive: true });
+
+ const alertFile = path.join(logDir, `high_threat_${date}.log`);
+ await fs.appendFile(alertFile, message + "\n");
+ }
+ } catch (err) {
+ // Fail silently or log to error log, depending on requirements
+ winstonLogger.error(`Failed to log security event: ${err.message}`);
+ }
+}
+
+module.exports = {
+ captureSecurityData,
+ analyzeThreatLevel,
+ logSecurityEvent,
+};
diff --git a/src/utils/validateEmail.js b/src/utils/validateEmail.js
deleted file mode 100644
index 249b95c..0000000
--- a/src/utils/validateEmail.js
+++ /dev/null
@@ -1,34 +0,0 @@
-const validator = require("validator");
-
-const MESSAGES = {
- REQUIRED: "Email is required",
- TOO_LONG: "Email address is too long",
- INVALID: "Please enter a valid email address",
-};
-
-const MAX_EMAIL_LENGTH = 254;
-
-const validateEmail = (email) => {
- if (!email || typeof email !== "string") {
- return { valid: false, message: MESSAGES.REQUIRED };
- }
-
- email = email.trim().toLowerCase();
-
- if (email.length > MAX_EMAIL_LENGTH) {
- return { valid: false, message: MESSAGES.TOO_LONG };
- }
-
- if (
- !validator.isEmail(email) ||
- email.includes("..") ||
- email.startsWith(".") ||
- email.endsWith(".")
- ) {
- return { valid: false, message: MESSAGES.INVALID };
- }
-
- return { valid: true, email };
-};
-
-module.exports = validateEmail;
diff --git a/src/views/pages/authRedirect.handlebars b/src/views/pages/authRedirect.handlebars
index 23bb188..cd0f318 100644
--- a/src/views/pages/authRedirect.handlebars
+++ b/src/views/pages/authRedirect.handlebars
@@ -1,4 +1,4 @@
-
+
Redirecting...
Please wait while we redirect you to the authentication service.
If you are not redirected automatically, click here .
diff --git a/src/views/pages/sitemap.handlebars b/src/views/pages/sitemap.handlebars
index 38dee42..472683d 100644
--- a/src/views/pages/sitemap.handlebars
+++ b/src/views/pages/sitemap.handlebars
@@ -1,52 +1,35 @@
{{!-- views/pages/sitemap.handlebars --}}
+
{{#section "styles"}}
{{/section}}
+{{!-- Inline recursive partial for sitemap nodes --}}
+{{#*inline "sitemapNode"}}
+
+ {{#if children}}
+
+ {{#if title}}{{title}}{{else}}{{label}}{{/if}}
+
+
+ {{#each children}}
+ {{> sitemapNode}}
+ {{/each}}
+
+ {{else}}
+
+ {{#if title}}{{title}}{{else if label}}{{label}}{{else}}{{loc}}{{/if}}
+
+ {{#if lastmod}}({{lastmod}}) {{/if}}
+ {{/if}}
+
+{{/inline}}
+
Site Map
diff --git a/src/views/partials/footer.handlebars b/src/views/partials/footer.handlebars
index 167bc75..b860df0 100644
--- a/src/views/partials/footer.handlebars
+++ b/src/views/partials/footer.handlebars
@@ -2,9 +2,9 @@
To the extent possible under law, {{siteOwner}} has waived all copyright and related or neighboring rights to this
work
-
+
{{#if adminLoginUrl}}
- .
+ .
{{else}}
.
{{/if}}
diff --git a/src/views/partials/siteNav.handlebars b/src/views/partials/siteNav.handlebars
index 2050a7f..14489d7 100644
--- a/src/views/partials/siteNav.handlebars
+++ b/src/views/partials/siteNav.handlebars
@@ -11,7 +11,7 @@
{{#each submenu}}
{{#if this.method}}
-
{{else}}
diff --git a/yarn.lock b/yarn.lock
index 17188dd..4fc32a9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4248,6 +4248,11 @@
resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
+validator@^13.15.15:
+ version "13.15.15"
+ resolved "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz"
+ integrity sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==
+
vary@^1.1.2, vary@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"