diff --git a/.gitignore b/.gitignore index a973909..03eb3fc 100755 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ *.sqlite /logs/*/*.json *.gz +data/* +!data/.gitkeep diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/data/.gitkeep diff --git a/data/logs.sqlite3-shm b/data/logs.sqlite3-shm deleted file mode 100644 index 5fe3ba0..0000000 --- a/data/logs.sqlite3-shm +++ /dev/null Binary files differ diff --git a/data/logs.sqlite3-wal b/data/logs.sqlite3-wal deleted file mode 100644 index 8ac0b30..0000000 --- a/data/logs.sqlite3-wal +++ /dev/null Binary files differ diff --git a/public/css/styles.css b/public/css/styles.css index 5a4a5c5..727dda4 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -175,18 +175,34 @@ display: flex; gap: 1rem; } -header#site-header nav.site-nav a { +header#site-header nav.site-nav a, +nav.site-nav .dropdown-content form button { text-decoration: none; font-weight: 600; padding: 0.3rem 0.6rem; border-radius: 3px; transition: background-color 0.2s ease-in-out; } +nav.site-nav .dropdown-content form button { + all: unset; + display: block; + width: 100%; + padding: 0.3rem 0.6rem; + color: #2c3e50; + cursor: pointer; + font: inherit; + font-weight: 600; /* Match the link font weight */ + text-align: left; + user-select: none; + box-sizing: border-box; +} header#site-header nav.site-nav > a { color: #ecf0f1; } header#site-header nav.site-nav a:hover, -header#site-header nav.site-nav a:focus { +header#site-header nav.site-nav a:focus, +nav.site-nav .dropdown-content form button:hover, +nav.site-nav .dropdown-content form button:focus { background-color: #34495e; } nav.site-nav .dropdown { @@ -207,13 +223,15 @@ } nav.site-nav .dropdown-content a { display: block; - padding: 12px 16px; + padding: 0.3rem 0.6rem; text-decoration: none; - color: #2c3e50;; /* fixme */ + color: #2c3e50; + font-weight: 600; /* Match the button font weight */ } -nav.site-nav .dropdown-content a:hover { +nav.site-nav .dropdown-content a:hover, +nav.site-nav .dropdown-content form button:hover { color: #ecf0f1; - background-color: #f1f1f1; + background-color: #34495e; /* Match the main nav hover color */ } nav.site-nav .dropdown:hover .dropdown-content { display: block; @@ -225,6 +243,9 @@ font-size: 1.5em; padding: 0.5em; } +nav.site-nav .dropdown-content form { + margin: 0; +} /* Reset */ * { margin: 0; diff --git a/src/css/nav.css b/src/css/nav.css index caad281..86dfc58 100644 --- a/src/css/nav.css +++ b/src/css/nav.css @@ -3,7 +3,8 @@ gap: 1rem; } -header#site-header nav.site-nav a { +header#site-header nav.site-nav a, +nav.site-nav .dropdown-content form button { text-decoration: none; font-weight: 600; padding: 0.3rem 0.6rem; @@ -11,12 +12,28 @@ transition: background-color 0.2s ease-in-out; } +nav.site-nav .dropdown-content form button { + all: unset; + display: block; + width: 100%; + padding: 0.3rem 0.6rem; + color: #2c3e50; + cursor: pointer; + font: inherit; + font-weight: 600; /* Match the link font weight */ + text-align: left; + user-select: none; + box-sizing: border-box; +} + header#site-header nav.site-nav > a { color: #ecf0f1; } header#site-header nav.site-nav a:hover, -header#site-header nav.site-nav a:focus { +header#site-header nav.site-nav a:focus, +nav.site-nav .dropdown-content form button:hover, +nav.site-nav .dropdown-content form button:focus { background-color: #34495e; } @@ -41,14 +58,16 @@ nav.site-nav .dropdown-content a { display: block; - padding: 12px 16px; + padding: 0.3rem 0.6rem; text-decoration: none; - color: #2c3e50;; /* fixme */ + color: #2c3e50; + font-weight: 600; /* Match the button font weight */ } -nav.site-nav .dropdown-content a:hover { +nav.site-nav .dropdown-content a:hover, +nav.site-nav .dropdown-content form button:hover { color: #ecf0f1; - background-color: #f1f1f1; + background-color: #34495e; /* Match the main nav hover color */ } nav.site-nav .dropdown:hover .dropdown-content { @@ -62,3 +81,7 @@ font-size: 1.5em; padding: 0.5em; } + +nav.site-nav .dropdown-content form { + margin: 0; +} diff --git a/src/middleware/applyProductionSecurity.js b/src/middleware/applyProductionSecurity.js index 414fce5..cb3e8d2 100644 --- a/src/middleware/applyProductionSecurity.js +++ b/src/middleware/applyProductionSecurity.js @@ -7,7 +7,6 @@ const applyProductionSecurity = [ (req, res, next) => { req.app.disable("x-powered-by"); - req.app.set("trust proxy", true); next(); }, (req, res, next) => { diff --git a/src/middleware/baseContext.js b/src/middleware/baseContext.js index e8cb659..8676f74 100644 --- a/src/middleware/baseContext.js +++ b/src/middleware/baseContext.js @@ -3,7 +3,15 @@ module.exports = async function baseContextMiddleware(req, res, next) { const isAuthenticated = req.isAuthenticated; - const baseContext = await getBaseContext(isAuthenticated); + + const scheme = req.protocol; + const host = req.get("host"); + const requestUri = req.originalUrl; + const rd = `${scheme}://${host}${requestUri}`; + + const adminLoginUrl = `${process.env.AUTH_LOGIN}${encodeURIComponent(rd)}`; + + const baseContext = await getBaseContext(isAuthenticated, { adminLoginUrl }); res.locals.baseContext = baseContext; res.renderWithBaseContext = (template, overrides = {}) => { diff --git a/src/middleware/index.js b/src/middleware/index.js index ebce2ee..465f864 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -24,6 +24,7 @@ const app = express(); const excludedPaths = ["/contact", "/analytics", "/track"]; const DATA_LIMIT_BYTES = 10 * 1024; // 10k + app.set("trust proxy", true); // General parsers for non-excluded routes app.use((req, res, next) => { diff --git a/src/routes/contact.js b/src/routes/contact.js index a46b114..8082124 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -43,7 +43,7 @@ const express = require("express"); const router = express.Router(); const sendContactMail = require("../utils/sendContactMail"); -const getBaseContext = require("../utils/baseContext"); +// const getBaseContext = require("../utils/baseContext"); const formLimiter = require("../utils/formLimiter"); const verifyHCaptcha = require("../utils/verifyHCaptcha"); const crypto = require("crypto"); @@ -57,31 +57,41 @@ 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' + "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' + "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 - ] + /\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, @@ -91,24 +101,24 @@ secure: req.secure, hostname: req.hostname, originalUrl: req.originalUrl, - encrypted: req.socket?.encrypted || false + 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'], + 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'] + 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 @@ -119,7 +129,7 @@ query: req.query, timestamp: timestamp, requestStart: req._startTime || Date.now(), - processingTime: Date.now() - (req._startTime || Date.now()) + processingTime: Date.now() - (req._startTime || Date.now()), }; // TLS/Security info @@ -130,10 +140,10 @@ tlsData = { cipher: cipher, tlsVersion: req.socket.getProtocol ? req.socket.getProtocol() : null, - authorized: req.socket.authorized + authorized: req.socket.authorized, }; } catch (err) { - tlsData = { error: 'TLS data unavailable' }; + tlsData = { error: "TLS data unavailable" }; } } @@ -144,7 +154,7 @@ security: securityHeaders, request: requestData, tls: tlsData, - additional: additionalData + additional: additionalData, }; } @@ -154,12 +164,12 @@ const indicators = []; // Check message content for suspicious patterns - const message = formData.message?.toLowerCase() || ''; - const email = formData.email?.toLowerCase() || ''; - const name = formData.name?.toLowerCase() || ''; + const message = formData.message?.toLowerCase() || ""; + const email = formData.email?.toLowerCase() || ""; + const name = formData.name?.toLowerCase() || ""; // Suspicious keywords in message - THREAT_PATTERNS.suspiciousKeywords.forEach(keyword => { + THREAT_PATTERNS.suspiciousKeywords.forEach((keyword) => { if (message.includes(keyword.toLowerCase())) { threatScore += 3; indicators.push(`suspicious_keyword: ${keyword}`); @@ -167,7 +177,7 @@ }); // Check for suspicious email domains - const emailDomain = email.split('@')[1]; + const emailDomain = email.split("@")[1]; if (emailDomain && THREAT_PATTERNS.suspiciousDomains.includes(emailDomain)) { threatScore += 5; indicators.push(`suspicious_email_domain: ${emailDomain}`); @@ -182,59 +192,65 @@ }); // Check for rapid form submission (potential automation) - if (securityData.additional.clientData?.formTime < 5000) { // Less than 5 seconds + if (securityData.additional.clientData?.formTime < 5000) { + // Less than 5 seconds threatScore += 2; - indicators.push('rapid_submission'); + indicators.push("rapid_submission"); } // Check for suspicious user agent - const userAgent = securityData.security.userAgent || ''; - if (!userAgent || userAgent.includes('bot') || userAgent.includes('crawl')) { + const userAgent = securityData.security.userAgent || ""; + if (!userAgent || userAgent.includes("bot") || userAgent.includes("crawl")) { threatScore += 3; - indicators.push('suspicious_user_agent'); + indicators.push("suspicious_user_agent"); } // Check for missing referer (direct access) if (!securityData.security.referer) { threatScore += 1; - indicators.push('no_referer'); + indicators.push("no_referer"); } // Determine threat level - let threatLevel = 'low'; - if (threatScore >= 8) threatLevel = 'high'; - else if (threatScore >= 4) threatLevel = 'medium'; + let threatLevel = "low"; + if (threatScore >= 8) threatLevel = "high"; + else if (threatScore >= 4) threatLevel = "medium"; return { score: threatScore, level: threatLevel, indicators: indicators, - requiresReview: threatScore >= 4 + requiresReview: threatScore >= 4, }; } // Enhanced logging with threat analysis -async function logSecurityEvent(data, eventType = 'contact_submission') { +async function logSecurityEvent(data, eventType = "contact_submission") { try { - const logDir = path.join(__dirname, '..', 'logs', 'security'); + const logDir = path.join(__dirname, "..", "logs", "security"); await fs.mkdir(logDir, { recursive: true }); - - const logFile = path.join(logDir, `${eventType}_${new Date().toISOString().split('T')[0]}.log`); + + const logFile = path.join( + logDir, + `${eventType}_${new Date().toISOString().split("T")[0]}.log` + ); const logEntry = { ...data, - loggedAt: new Date().toISOString() + loggedAt: new Date().toISOString(), }; - - await fs.appendFile(logFile, JSON.stringify(logEntry) + '\n'); - + + await fs.appendFile(logFile, JSON.stringify(logEntry) + "\n"); + // Create separate high-threat log - if (data.threatAnalysis?.level === 'high') { - const alertFile = path.join(logDir, `high_threat_${new Date().toISOString().split('T')[0]}.log`); - await fs.appendFile(alertFile, JSON.stringify(logEntry) + '\n'); + if (data.threatAnalysis?.level === "high") { + const alertFile = path.join( + logDir, + `high_threat_${new Date().toISOString().split("T")[0]}.log` + ); + await fs.appendFile(alertFile, JSON.stringify(logEntry) + "\n"); } - } catch (err) { - console.error('Failed to log security event:', err); + console.error("Failed to log security event:", err); } } @@ -246,14 +262,17 @@ router.post("/contact", formLimiter, async (req, res, next) => { try { - const { name, email, message, subject, hcaptchaToken, clientData } = req.body; + 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; + return ( + typeof str === "string" && str.trim().length > 0 && str.length <= maxLen + ); } if ( @@ -264,11 +283,11 @@ ) { const invalidData = captureSecurityData(req, { formData: { name, email, subject, message }, - failureReason: 'invalid_input', - processingStep: 'validation' + failureReason: "invalid_input", + processingStep: "validation", }); - await logSecurityEvent(invalidData, 'validation_failure'); + await logSecurityEvent(invalidData, "validation_failure"); return next(new HttpError("Invalid input", 400)); } // Capture security data @@ -276,54 +295,66 @@ formData: { name, email, message, subject }, captchaProvided: !!hcaptchaToken, clientData: clientData, // From client-side - processingStep: 'initial_validation' + processingStep: "initial_validation", }); // Analyze threat level const threatAnalysis = analyzeThreatLevel( - { name, email, message, subject }, + { name, email, message, subject }, securityData ); // Enhanced logging with threat analysis - await logSecurityEvent({ - ...securityData, - threatAnalysis: threatAnalysis, - formData: { name, email, hasMessage: !!message, hasSubject: !!subject } - }, 'contact_submission'); + await logSecurityEvent( + { + ...securityData, + threatAnalysis: threatAnalysis, + formData: { name, email, hasMessage: !!message, hasSubject: !!subject }, + }, + "contact_submission" + ); // CAPTCHA validation if (!hcaptchaToken) { - await logSecurityEvent({ - ...securityData, - threatAnalysis: threatAnalysis, - validationResult: 'failed', - failureReason: 'missing_captcha' - }, 'validation_failure'); - + await logSecurityEvent( + { + ...securityData, + threatAnalysis: threatAnalysis, + validationResult: "failed", + failureReason: "missing_captcha", + }, + "validation_failure" + ); + return next(new HttpError("Captcha token missing", 400)); } - + const valid = await verifyHCaptcha(hcaptchaToken); if (!valid) { - await logSecurityEvent({ - ...securityData, - threatAnalysis: threatAnalysis, - validationResult: 'failed', - failureReason: 'captcha_failed' - }, 'validation_failure'); - + await logSecurityEvent( + { + ...securityData, + threatAnalysis: threatAnalysis, + validationResult: "failed", + failureReason: "captcha_failed", + }, + "validation_failure" + ); + return next(new HttpError("Captcha verification failed", 400)); } // High threat handling - if (threatAnalysis.level === 'high') { - await logSecurityEvent({ - ...securityData, - threatAnalysis: threatAnalysis, - action: 'blocked_high_threat' - }, 'threat_blocked'); - + if (threatAnalysis.level === "high") { + await logSecurityEvent( + { + ...securityData, + threatAnalysis: threatAnalysis, + action: "blocked_high_threat", + }, + "threat_blocked" + ); + // Still redirect to thank you to not reveal detection res.redirect("/contact/thankyou"); return; @@ -331,62 +362,64 @@ // Send email (but flag for review if medium threat) const emailData = { name, email, message, subject }; - if (threatAnalysis.level === 'medium') { + if (threatAnalysis.level === "medium") { emailData.securityFlag = `[SECURITY REVIEW REQUIRED - Score: ${threatAnalysis.score}]`; } - + await sendContactMail(emailData); - + // Log successful completion - await logSecurityEvent({ - ...securityData, - threatAnalysis: threatAnalysis, - processingResult: 'success', - emailSent: true - }, 'contact_success'); - + await logSecurityEvent( + { + ...securityData, + threatAnalysis: threatAnalysis, + processingResult: "success", + emailSent: true, + }, + "contact_success" + ); + res.redirect("/contact/thankyou"); - } catch (err) { const errorData = captureSecurityData(req, { error: { message: err.message, stack: err.stack, - name: err.name + name: err.name, }, - processingStep: 'error_handling' + processingStep: "error_handling", }); - - await logSecurityEvent(errorData, 'contact_error'); + + await logSecurityEvent(errorData, "contact_error"); next(err); } }); router.get("/contact", async (req, res) => { const securityData = captureSecurityData(req, { - pageAccess: 'contact_form', - processingStep: 'page_render' + pageAccess: "contact_form", + processingStep: "page_render", }); - - await logSecurityEvent(securityData, 'page_access'); - + + await logSecurityEvent(securityData, "page_access"); + const context = { csrfToken: res.locals.csrfToken, title: "Contact", formAction: qualifyLink("/contact"), - formMethod: "POST" + formMethod: "POST", }; res.renderWithBaseContext("pages/contact.handlebars", context); }); router.get("/contact/thankyou", async (req, res) => { const securityData = captureSecurityData(req, { - pageAccess: 'thankyou_page', - processingStep: 'page_render' + pageAccess: "thankyou_page", + processingStep: "page_render", }); - - await logSecurityEvent(securityData, 'thankyou_access'); - + + await logSecurityEvent(securityData, "thankyou_access"); + res.renderWithBaseContext("pages/thankyou.handlebars", { title: "Thank You", }); diff --git a/src/routes/errorPage.js b/src/routes/errorPage.js index ca2efc1..1140b49 100644 --- a/src/routes/errorPage.js +++ b/src/routes/errorPage.js @@ -1,5 +1,4 @@ // src/routes/errorPage -const getBaseContext = require("../utils/baseContext"); const { getErrorContext } = require("../utils/errorContext"); module.exports = async (req, res) => { @@ -13,6 +12,6 @@ content: "", }; - res.status(errorContext.statusCode) + res.status(errorContext.statusCode); res.renderWithBaseContext("pages/error", context); }; diff --git a/src/routes/post.js b/src/routes/post.js index 3422cf3..ba9a8c7 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -4,8 +4,7 @@ const path = require("path"); const matter = require("gray-matter"); -const getBaseContext = require("../utils/baseContext"); -const HttpError = require("../utils/HttpError") +const HttpError = require("../utils/HttpError"); module.exports = async (req, res, next) => { const { year, month, name } = req.params; @@ -45,7 +44,7 @@ date: frontmatter.date, author: frontmatter.author, content: htmlContent, - } + }; res.renderWithBaseContext("pages/post", context); } catch (err) { next(new HttpError("The requested blog post could not be found.", 404)); diff --git a/src/routes/sitemap.js b/src/routes/sitemap.js index a6dafcf..b6cd6e1 100644 --- a/src/routes/sitemap.js +++ b/src/routes/sitemap.js @@ -4,7 +4,6 @@ const fs = require("fs"); const path = require("path"); const Handlebars = require("handlebars"); -const getBaseContext = require("../utils/baseContext"); const sitemapService = require("../services/sitemapService"); const { qualifyLink } = require("../utils/qualifyLinks.js"); // const { baseUrl } = require("../utils/baseUrl"); @@ -29,7 +28,7 @@ const context = { title: "Site Map", sitemap: await sitemapService.getCompleteSitemap(), - } + }; res.renderWithBaseContext("pages/sitemap", context); }); @@ -52,5 +51,4 @@ res.type("application/xml").send(xml); }); - module.exports = router; diff --git a/src/utils/ConstructionRoutes.js b/src/utils/ConstructionRoutes.js index f5e2fee..af413cb 100644 --- a/src/utils/ConstructionRoutes.js +++ b/src/utils/ConstructionRoutes.js @@ -1,6 +1,5 @@ // src/utils/ConstructionRoutes.js const BaseRoute = require("./BaseRoute"); -const getBaseContext = require("./baseContext"); class ConstructionRoutes extends BaseRoute { constructor() { diff --git a/src/utils/MarkdownRoutes.js b/src/utils/MarkdownRoutes.js index 9502c5c..e91a4b4 100644 --- a/src/utils/MarkdownRoutes.js +++ b/src/utils/MarkdownRoutes.js @@ -4,7 +4,6 @@ const path = require("path"); const matter = require("gray-matter"); const { marked } = require("marked"); -const getBaseContext = require("./baseContext"); class MarkdownRoutes extends BaseRoute { constructor() { @@ -24,7 +23,7 @@ const context = { title: frontmatter.title, content: htmlContent, - } + }; res.renderWithBaseContext(`pages/${handlebarsFile}`, context); } catch (err) { err.statusCode = 500; diff --git a/src/views/partials/footer.handlebars b/src/views/partials/footer.handlebars index c9fb64f..167bc75 100644 --- a/src/views/partials/footer.handlebars +++ b/src/views/partials/footer.handlebars @@ -1,7 +1,14 @@
To the extent possible under law, {{siteOwner}} has waived all copyright and related or neighboring rights to this
- work.
+ work
+
+ {{#if adminLoginUrl}}
+ .
+ {{else}}
+ .
+ {{/if}}
+
diff --git a/src/views/partials/headers.handlebars b/src/views/partials/headers.handlebars
index d0f1030..0b69c6b 100644
--- a/src/views/partials/headers.handlebars
+++ b/src/views/partials/headers.handlebars
@@ -4,26 +4,7 @@
{{siteOwner}}