diff --git a/src/api/posts.js b/src/api/posts.js deleted file mode 100644 index e69de29..0000000 --- a/src/api/posts.js +++ /dev/null diff --git a/src/app.js b/src/app.js index c7996f7..c2b0aab 100644 --- a/src/app.js +++ b/src/app.js @@ -1,28 +1,36 @@ // src/app.js -console.log("CWD:", process.cwd()); - require("dotenv").config(); + const setupMiddleware = require("./middleware"); - const { manualLogger } = require("./utils/logging"); -// const path = require("path"); - const { startTokenCleanup } = require("./utils/tokenCleanup"); + +const PORT = process.env.PORT || 3400; +const CWD_LOG = `CWD: ${process.cwd()}`; +const SERVER_LISTEN_LOG = (port) => + `Server listening on http://localhost:${port}`; +const NODE_ENV_LOG = `NODE_ENV: ${process.env.NODE_ENV}`; +const UNCUGHT_EXCEPTION_MSG = "Uncaught Exception:"; +const UNHANDLED_REJECTION_MSG = "Unhandled Rejection:"; + +function handleUncaughtException(err) { + manualLogger.error(UNCUGHT_EXCEPTION_MSG, err.stack || err); +} + +function handleUnhandledRejection(reason) { + manualLogger.error(UNHANDLED_REJECTION_MSG, reason?.stack || reason); +} + +console.log(CWD_LOG); + startTokenCleanup(); -app = setupMiddleware(); +const app = setupMiddleware(); -port = process.env.PORT || 3400; - -app.listen(port, () => { - console.log(`Server listening on http://localhost:${port}`); - console.log(`NODE_ENV: ${process.env.NODE_ENV}`); +app.listen(PORT, () => { + console.log(SERVER_LISTEN_LOG(PORT)); + console.log(NODE_ENV_LOG); }); -process.on("uncaughtException", (err) => { - manualLogger.error("Uncaught Exception:", err.stack || err); -}); - -process.on("unhandledRejection", (reason, promise) => { - manualLogger.error("Unhandled Rejection:", reason?.stack || reason); -}); +process.on("uncaughtException", handleUncaughtException); +process.on("unhandledRejection", handleUnhandledRejection); diff --git a/src/constants/authConstants.js b/src/constants/authConstants.js new file mode 100644 index 0000000..a3a0a18 --- /dev/null +++ b/src/constants/authConstants.js @@ -0,0 +1,16 @@ +// constants/authConstants.js +const VERIFY_URL = process.env.AUTH_VERIFY; +const CACHE_TTL = parseInt(process.env.AUTH_CACHE_TTL, 10) || 120000; // 2 minutes default +const AUTH_TIMEOUT_MS = 5000; // 5 second timeout + +const LOG_MESSAGES = { + AUTH_SERVER_UNAVAILABLE: + "[AuthCheck] Auth server unavailable, continuing unauthenticated", +}; + +module.exports = { + VERIFY_URL, + CACHE_TTL, + AUTH_TIMEOUT_MS, + LOG_MESSAGES, +}; diff --git a/src/constants/errorConstants.js b/src/constants/errorConstants.js new file mode 100644 index 0000000..21e37bf --- /dev/null +++ b/src/constants/errorConstants.js @@ -0,0 +1,14 @@ +const DEFAULT_ERROR_MESSAGE = "Internal Server Error"; +const DEFAULT_STACK_TRACE = "No stack trace available"; +const DEFAULT_STATUS_CODE = 500; +const DEFAULT_LOG_LEVEL = "error"; +const ERROR_VIEW = "pages/error"; +const ERROR_REDIRECT_PATH = "/error"; +module.exports = { + DEFAULT_ERROR_MESSAGE, + DEFAULT_STACK_TRACE, + DEFAULT_STATUS_CODE, + DEFAULT_LOG_LEVEL, + ERROR_VIEW, + ERROR_REDIRECT_PATH, +}; diff --git a/src/constants/hbsConstants.js b/src/constants/hbsConstants.js new file mode 100644 index 0000000..8e8cb8f --- /dev/null +++ b/src/constants/hbsConstants.js @@ -0,0 +1,20 @@ +// constants/hbsConstants.js +const VIEW_ENGINE = "handlebars"; +const LAYOUTS_DIR = "../views/layouts"; +const PARTIALS_DIR = "../views/partials"; +const DEFAULT_LAYOUT = "main"; +const EXTENSION = ".handlebars"; + +const RUNTIME_OPTIONS = { + allowProtoPropertiesByDefault: true, + allowProtoMethodsByDefault: true, +}; + +module.exports = { + VIEW_ENGINE, + LAYOUTS_DIR, + PARTIALS_DIR, + DEFAULT_LAYOUT, + EXTENSION, + RUNTIME_OPTIONS, +}; diff --git a/src/constants/htmlFormatConstants.js b/src/constants/htmlFormatConstants.js new file mode 100644 index 0000000..58a3b5c --- /dev/null +++ b/src/constants/htmlFormatConstants.js @@ -0,0 +1,15 @@ +// constants/htmlFormatConstants.js +const BEAUTIFY_OPTIONS = { + indent_size: 2, + wrap_line_length: 80, + end_with_newline: true, +}; + +const ERROR_MESSAGES = { + BEAUTIFY_ERROR: "Beautify error:", +}; + +module.exports = { + BEAUTIFY_OPTIONS, + ERROR_MESSAGES, +}; diff --git a/src/constants/httpLimits.js b/src/constants/httpLimits.js new file mode 100644 index 0000000..2f7878d --- /dev/null +++ b/src/constants/httpLimits.js @@ -0,0 +1,12 @@ +// constants/httpLimits.js +const ALLOWED_HTTP_METHODS = ["HEAD", "GET", "POST"]; +const MAX_HEADER_COUNT = 100; +const DISALLOWED_CONTENT_TYPE_SUBSTRINGS = ["multipart/form-data"]; +const MAX_CONTENT_LENGTH = 4096; + +module.exports = { + ALLOWED_HTTP_METHODS, + MAX_HEADER_COUNT, + DISALLOWED_CONTENT_TYPE_SUBSTRINGS, + MAX_CONTENT_LENGTH, +}; diff --git a/src/constants/httpMessages.js b/src/constants/httpMessages.js new file mode 100644 index 0000000..ce80266 --- /dev/null +++ b/src/constants/httpMessages.js @@ -0,0 +1,9 @@ +// constants/httpMessages.js +const HTTP_ERRORS = { + METHOD_NOT_ALLOWED: (method) => `Http Method '${method}' Not Allowed`, + PAYLOAD_TOO_LARGE: "Payload Too Large", + FILE_UPLOADS_NOT_ALLOWED: "File uploads are not allowed.", + TOO_MANY_HEADERS: "Too many headers.", +}; + +module.exports = { HTTP_ERRORS }; diff --git a/src/constants/middlewareConstants.js b/src/constants/middlewareConstants.js new file mode 100644 index 0000000..53fadf3 --- /dev/null +++ b/src/constants/middlewareConstants.js @@ -0,0 +1,17 @@ +const TRUST_PROXY = true; +const EXCLUDED_PATHS = ["/contact", "/analytics", "/track"]; +const DATA_LIMIT_BYTES = 10 * 1024; +const RAW_BODY_LIMIT_BYTES = 100 * 1024; +const RAW_BODY_TYPE = "*/*"; +const FALLBACK_ENCODING = "utf8"; +const FALLBACK_BODY = {}; + +module.exports = { + TRUST_PROXY, + EXCLUDED_PATHS, + DATA_LIMIT_BYTES, + RAW_BODY_LIMIT_BYTES, + RAW_BODY_TYPE, + FALLBACK_ENCODING, + FALLBACK_BODY, +}; diff --git a/src/constants/newsletterConstants.js b/src/constants/newsletterConstants.js new file mode 100644 index 0000000..bfeed51 --- /dev/null +++ b/src/constants/newsletterConstants.js @@ -0,0 +1,20 @@ +// constants/newsletterConstants.js +const FILE_PATH = require("path").join( + __dirname, + "../../data/newsletter-emails.json" +); + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const ERRORS = { + INVALID_EMAIL: "Invalid email format", + PARSE_FAILURE: "Failed to parse newsletter-emails.json", + WRITE_FAILURE: "writeFile failed", + SAVE_EMAIL_FAILURE: "Failed to save email", +}; + +module.exports = { + FILE_PATH, + EMAIL_REGEX, + ERRORS, +}; diff --git a/src/constants/rssConstants.js b/src/constants/rssConstants.js new file mode 100644 index 0000000..a7cd313 --- /dev/null +++ b/src/constants/rssConstants.js @@ -0,0 +1,10 @@ +// src/constants/rssConstants.js +const FEED_TITLE = "My Blog"; +const FEED_DESCRIPTION = "Latest posts from my blog"; +const FEED_LANGUAGE = "en"; + +module.exports = { + FEED_TITLE, + FEED_DESCRIPTION, + FEED_LANGUAGE, +}; diff --git a/src/constants/securityConstants.js b/src/constants/securityConstants.js new file mode 100644 index 0000000..abb2119 --- /dev/null +++ b/src/constants/securityConstants.js @@ -0,0 +1,24 @@ +// config/securityConstants.js + +module.exports = { + LOCALHOST_HOSTNAMES: ["127.0.0.1", "localhost"], + HEALTHCHECK_METHOD: "HEAD", + HEALTHCHECK_PATH: "/healthcheck", + FORBIDDEN_MESSAGE: "Forbidden", + FORBIDDEN_STATUS_CODE: 403, + HSTS_MAX_AGE: 63072000, + CSP_DIRECTIVES: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "https://hcaptcha.com"], + styleSrc: ["'self'", "https:"], + imgSrc: [ + "'self'", + "data:", + "https://licensebuttons.net", + "https://cdn.jsdelivr.net", + ], + frameSrc: ["'self'", "https://newassets.hcaptcha.com"], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, +}; diff --git a/src/constants/sitemapConstants.js b/src/constants/sitemapConstants.js new file mode 100644 index 0000000..84ffdb1 --- /dev/null +++ b/src/constants/sitemapConstants.js @@ -0,0 +1,17 @@ +// constants/sitemapConstants.js +const STATIC_SITEMAP_PATH = "../../content/sitemap.json"; +const POSTS_PATH = "../../content/posts"; + +const DEFAULT_CHANGEFREQ = "monthly"; +const DEFAULT_PRIORITY = "0.5"; +const BLOG_POST_CHANGEFREQ = "monthly"; +const BLOG_POST_PRIORITY = "0.7"; + +module.exports = { + STATIC_SITEMAP_PATH, + POSTS_PATH, + DEFAULT_CHANGEFREQ, + DEFAULT_PRIORITY, + BLOG_POST_CHANGEFREQ, + BLOG_POST_PRIORITY, +}; diff --git a/src/middleware/applyProductionSecurity.js b/src/middleware/applyProductionSecurity.js index cb3e8d2..e813ec9 100644 --- a/src/middleware/applyProductionSecurity.js +++ b/src/middleware/applyProductionSecurity.js @@ -3,51 +3,50 @@ const xssSanitizer = require("./xssSanitizer"); const HttpError = require("../utils/HttpError"); const { baseUrl } = require("../utils/baseUrl"); +const { + LOCALHOST_HOSTNAMES, + HEALTHCHECK_METHOD, + HEALTHCHECK_PATH, + FORBIDDEN_MESSAGE, + FORBIDDEN_STATUS_CODE, + HSTS_MAX_AGE, + CSP_DIRECTIVES, +} = require("../constants/securityConstants"); + +const disablePoweredBy = (req, res, next) => { + req.app.disable("x-powered-by"); + next(); +}; + +const logIps = (req, res, next) => { + const forwardedIp = req.ip; + const directIp = req.connection.remoteAddress; + req.log?.info?.(`Forwarded IP: ${forwardedIp}`); + req.log?.info?.(`Direct IP: ${directIp}`); + next(); +}; + +const blockLocalhostAccess = (req, res, next) => { + if (req.method === HEALTHCHECK_METHOD && req.path === HEALTHCHECK_PATH) { + return next(); + } + if (LOCALHOST_HOSTNAMES.includes(req.hostname)) { + req.log.info(`Method: ${req.method} Path ${req.path}`); + return next(new HttpError(FORBIDDEN_MESSAGE, FORBIDDEN_STATUS_CODE)); + } + next(); +}; const applyProductionSecurity = [ - (req, res, next) => { - req.app.disable("x-powered-by"); - next(); - }, - (req, res, next) => { - const forwardedIp = req.ip; - const directIp = req.connection.remoteAddress; - - req.log?.info?.(`Forwarded IP: ${forwardedIp}`); - req.log?.info?.(`Direct IP: ${directIp}`); - next(); - }, + disablePoweredBy, + logIps, hpp(), xssSanitizer, // rateLimit middleware can be added here - (req, res, next) => { - const isHealthcheck = req.method === "HEAD" && req.path === "/healthcheck"; - if (isHealthcheck) return next(); - - const host = req.hostname; - if (["127.0.0.1", "localhost"].includes(host)) { - req.log.info(`Method: ${req.method} Path ${req.path}`); - return next(new HttpError("Forbidden", 403)); - } - - next(); - }, - helmet.hsts({ maxAge: 63072000 }), + blockLocalhostAccess, + helmet.hsts({ maxAge: HSTS_MAX_AGE }), helmet.contentSecurityPolicy({ - directives: { - defaultSrc: ["'self'", baseUrl], - scriptSrc: ["'self'", "https://hcaptcha.com"], - styleSrc: ["'self'", "https:"], - imgSrc: [ - "'self'", - "data:", - "https://licensebuttons.net", - "https://cdn.jsdelivr.net", - ], - frameSrc: ["'self'", "https://newassets.hcaptcha.com"], - objectSrc: ["'none'"], - upgradeInsecureRequests: [], - }, + directives: { ...CSP_DIRECTIVES, defaultSrc: ["'self'", baseUrl] }, }), ]; diff --git a/src/middleware/authCheck.js b/src/middleware/authCheck.js index 4660673..1f1432c 100644 --- a/src/middleware/authCheck.js +++ b/src/middleware/authCheck.js @@ -1,23 +1,23 @@ // middleware/authCheck.js const fetch = require("node-fetch"); - -const VERIFY_URL = process.env.AUTH_VERIFY; -const CACHE_TTL = parseInt(process.env.AUTH_CACHE_TTL) || 120000; // 2 minutes default +const { + VERIFY_URL, + CACHE_TTL, + AUTH_TIMEOUT_MS, + LOG_MESSAGES, +} = require("../constants/authConstants"); // Simple in-memory cache const authCache = new Map(); -// Helper to generate cache key function getCacheKey(cookie, authHeader) { return `${cookie}:${authHeader}`; } -// Helper to check if cache entry is valid function isCacheValid(entry) { return entry && Date.now() - entry.timestamp < CACHE_TTL; } -// Clean expired cache entries periodically setInterval(() => { const now = Date.now(); for (const [key, entry] of authCache.entries()) { @@ -25,60 +25,45 @@ authCache.delete(key); } } -}, CACHE_TTL); // Clean up when entries would expire +}, CACHE_TTL); module.exports = async (req, res, next) => { const cookie = req.headers["cookie"] || ""; const authHeader = req.headers["authorization"] || ""; const cacheKey = getCacheKey(cookie, authHeader); - // Check cache first const cached = authCache.get(cacheKey); if (isCacheValid(cached)) { req.isAuthenticated = cached.isAuthenticated; return next(); } - // Default to unauthenticated req.isAuthenticated = false; try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout + const timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS); const resVerify = await fetch(VERIFY_URL, { - headers: { - cookie, - authorization: authHeader, - }, + headers: { cookie, authorization: authHeader }, credentials: "include", signal: controller.signal, }); clearTimeout(timeout); - const isAuthenticated = resVerify.status === 200; + req.isAuthenticated = resVerify.status === 200; - // Cache the result authCache.set(cacheKey, { - isAuthenticated, + isAuthenticated: req.isAuthenticated, timestamp: Date.now(), }); - - req.isAuthenticated = isAuthenticated; - } catch (err) { - // Auth server down/timeout - silently fail, don't crash the app + } catch { req.isAuthenticated = false; - - // Optional: Log for debugging, but don't spam logs if (req.log) { - req.log.warn( - "[AuthCheck] Auth server unavailable, continuing unauthenticated" - ); + req.log.warn(LOG_MESSAGES.AUTH_SERVER_UNAVAILABLE); } else { - console.warn( - "[AuthCheck] Auth server unavailable, continuing unauthenticated" - ); + console.warn(LOG_MESSAGES.AUTH_SERVER_UNAVAILABLE); } } diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index c89f220..8ad85cb 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -4,18 +4,26 @@ const { getErrorContext } = require("../utils/errorContext"); const { buildErrorRenderContext } = require("../utils/buildErrorRenderContext"); const { isDev } = require("../utils/env"); +const { + DEFAULT_ERROR_MESSAGE, + DEFAULT_STACK_TRACE, + DEFAULT_STATUS_CODE, + DEFAULT_LOG_LEVEL, + ERROR_VIEW, + ERROR_REDIRECT_PATH, +} = require("../constants/errorConstants"); module.exports = async (err, req, res, next) => { - const statusCode = err.statusCode ?? 500; - const message = err.message ?? "Internal Server Error"; - const stack = err.stack ?? "No stack trace available"; + const statusCode = err.statusCode ?? DEFAULT_STATUS_CODE; + const message = err.message ?? DEFAULT_ERROR_MESSAGE; + const stack = err.stack ?? DEFAULT_STACK_TRACE; const code = err.code ?? null; const requestId = crypto.randomUUID?.() ?? Date.now().toString(36); const timestamp = new Date().toISOString(); const logEntry = { timestamp, - level: "error", + level: DEFAULT_LOG_LEVEL, requestId, method: req.method, url: req.originalUrl || req.url, @@ -38,7 +46,7 @@ const errorContext = getErrorContext(code || statusCode); if (!isDev) { - res.redirect(`/error?code=${errorContext.statusCode}`); + res.redirect(`${ERROR_REDIRECT_PATH}?code=${errorContext.statusCode}`); return; } @@ -54,5 +62,5 @@ }); const errorPageContext = await getBaseContext(req?.isAuthenticated, context); - res.status(errorContext.statusCode).render("pages/error", errorPageContext); + res.status(errorContext.statusCode).render(ERROR_VIEW, errorPageContext); }; diff --git a/src/middleware/formatHtml.js b/src/middleware/formatHtml.js index 8c02422..f842f80 100644 --- a/src/middleware/formatHtml.js +++ b/src/middleware/formatHtml.js @@ -1,22 +1,24 @@ // src/middleware/formatHtml.js const beautify = require("js-beautify").html; +const { + BEAUTIFY_OPTIONS, + ERROR_MESSAGES, +} = require("../constants/htmlFormatConstants"); module.exports = function (req, res, next) { const originalSend = res.send; res.send = function (body) { const contentType = res.get("Content-Type") || ""; - const isHTML = contentType.includes("text/html") || typeof body === "string" && body.trim().startsWith("<"); + const isHTML = + contentType.includes("text/html") || + (typeof body === "string" && body.trim().startsWith("<")); if (isHTML) { try { - body = beautify(body, { - indent_size: 2, - wrap_line_length: 80, - end_with_newline: true, - }); + body = beautify(body, BEAUTIFY_OPTIONS); } catch (e) { - console.error("Beautify error:", e); + console.error(ERROR_MESSAGES.BEAUTIFY_ERROR, e); } } @@ -25,4 +27,3 @@ next(); }; - diff --git a/src/middleware/hbs.js b/src/middleware/hbs.js index 08f9660..578db28 100644 --- a/src/middleware/hbs.js +++ b/src/middleware/hbs.js @@ -2,13 +2,21 @@ const path = require("path"); const exphbs = require("express-handlebars"); const { registerHelpers } = require("../utils/hbsHelpers"); +const { + VIEW_ENGINE, + LAYOUTS_DIR, + PARTIALS_DIR, + DEFAULT_LAYOUT, + EXTENSION, + RUNTIME_OPTIONS, +} = require("../constants/hbsConstants"); const hbsMiddleware = (req, res, next) => { if (!req.app.get("view engine")) { const hbs = exphbs.create({ - layoutsDir: path.join(__dirname, "../views/layouts"), - partialsDir: path.join(__dirname, "../views/partials"), - defaultLayout: "main", + layoutsDir: path.join(__dirname, LAYOUTS_DIR), + partialsDir: path.join(__dirname, PARTIALS_DIR), + defaultLayout: DEFAULT_LAYOUT, helpers: { section(name, options) { this._sections ??= {}; @@ -18,16 +26,13 @@ return null; }, }, - extname: ".handlebars", - runtimeOptions: { - allowProtoPropertiesByDefault: true, - allowProtoMethodsByDefault: true, - }, + extname: EXTENSION, + runtimeOptions: RUNTIME_OPTIONS, }); registerHelpers(hbs); - req.app.engine("handlebars", hbs.engine); - req.app.set("view engine", "handlebars"); + req.app.engine(VIEW_ENGINE, hbs.engine); + req.app.set("view engine", VIEW_ENGINE); req.app.set("views", path.join(__dirname, "../views")); } diff --git a/src/middleware/index.js b/src/middleware/index.js index 465f864..6ff00b6 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -22,13 +22,20 @@ function setupApp() { const app = express(); - const excludedPaths = ["/contact", "/analytics", "/track"]; - const DATA_LIMIT_BYTES = 10 * 1024; // 10k - app.set("trust proxy", true); + const { + TRUST_PROXY, + EXCLUDED_PATHS, + DATA_LIMIT_BYTES, + RAW_BODY_LIMIT_BYTES, + RAW_BODY_TYPE, + FALLBACK_ENCODING, + FALLBACK_BODY, + } = require("../constants/middlewareConstants"); + app.set("trust proxy", TRUST_PROXY); // General parsers for non-excluded routes app.use((req, res, next) => { - if (excludedPaths.includes(req.path)) return next(); + if (EXCLUDED_PATHS.includes(req.path)) return next(); express.json({ limit: DATA_LIMIT_BYTES })(req, res, (err) => { if (err) return next(err); express.urlencoded({ extended: false, limit: DATA_LIMIT_BYTES })( @@ -40,17 +47,20 @@ }); // Raw parser + manual truncation for excluded routes - const rawBodyParser = express.raw({ type: "*/*", limit: "100kb" }); + const rawBodyParser = express.raw({ + type: RAW_BODY_TYPE, + limit: RAW_BODY_LIMIT_BYTES, + }); app.use((req, res, next) => { - if (!excludedPaths.includes(req.path)) return next(); + if (!EXCLUDED_PATHS.includes(req.path)) return next(); rawBodyParser(req, res, (err) => { if (err) return next(err); try { - const raw = req.body.toString("utf8"); + const raw = req.body.toString(FALLBACK_ENCODING); const truncated = raw.slice(0, DATA_LIMIT_BYTES); req.body = JSON.parse(truncated); } catch (e) { - req.body = {}; // Fallback on parse failure + req.body = FALLBACK_BODY; // Fallback on parse failure } next(); }); diff --git a/src/middleware/validateRequestIntegrity.js b/src/middleware/validateRequestIntegrity.js index 8c49d4b..4d93bd0 100644 --- a/src/middleware/validateRequestIntegrity.js +++ b/src/middleware/validateRequestIntegrity.js @@ -1,24 +1,31 @@ +// middleware/validateHttpRequest.js const HttpError = require("../utils/HttpError"); +const { + ALLOWED_HTTP_METHODS, + MAX_HEADER_COUNT, + DISALLOWED_CONTENT_TYPE_SUBSTRINGS, +} = require("../constants/httpLimits"); +const { HTTP_ERRORS } = require("../constants/httpMessages"); + module.exports = (req, res, next) => { - const allowedMethods = ["HEAD", "GET", "POST"]; const contentLength = parseInt(req.get("content-length") || "0", 10); const contentType = req.headers["content-type"] || ""; const headerCount = Object.keys(req.headers).length; - if (!allowedMethods.includes(req.method)) { - return next(new HttpError(`Http Method '${req.method}' Not Allowed`, 405)); + if (!ALLOWED_HTTP_METHODS.includes(req.method)) { + return next(new HttpError(HTTP_ERRORS.METHOD_NOT_ALLOWED(req.method), 405)); } - if (contentLength > 4096) { - return next(new HttpError("Payload Too Large", 413)); + if (contentLength > MAX_CONTENT_LENGTH) { + return next(new HttpError(HTTP_ERRORS.PAYLOAD_TOO_LARGE, 413)); } - if (contentType.includes("multipart/form-data")) { - return next(new HttpError("File uploads are not allowed.", 400)); + if (DISALLOWED_CONTENT_TYPE_SUBSTRINGS.some((t) => contentType.includes(t))) { + return next(new HttpError(HTTP_ERRORS.FILE_UPLOADS_NOT_ALLOWED, 400)); } - if (headerCount > 100) { - return next(new HttpError("Too many headers.", 400)); + if (headerCount > MAX_HEADER_COUNT) { + return next(new HttpError(HTTP_ERRORS.TOO_MANY_HEADERS, 400)); } next(); diff --git a/src/presentation/postComponent.js b/src/presentation/postComponent.js deleted file mode 100644 index e69de29..0000000 --- a/src/presentation/postComponent.js +++ /dev/null diff --git a/src/presentation/render.js b/src/presentation/render.js deleted file mode 100644 index e69de29..0000000 --- a/src/presentation/render.js +++ /dev/null diff --git a/src/services/newsletterService.js b/src/services/newsletterService.js index 84ba7e9..be3a651 100644 --- a/src/services/newsletterService.js +++ b/src/services/newsletterService.js @@ -1,57 +1,52 @@ // src/services/newsletterService.js -let writeLock = Promise.resolve(); - const fs = require("fs").promises; const path = require("path"); +const { + FILE_PATH, + EMAIL_REGEX, + ERRORS, +} = require("../constants/newsletterConstants"); -const filePath = path.join(__dirname, "../../data/newsletter-emails.json"); +let writeLock = Promise.resolve(); -// Basic email regex validation function isValidEmail(email) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + return EMAIL_REGEX.test(email); } async function saveEmail(email) { try { if (!isValidEmail(email)) { - throw new Error("Invalid email format"); + throw new Error(ERRORS.INVALID_EMAIL); } - // Sanitize input: trim whitespace and lowercase const sanitizedEmail = email.trim().toLowerCase(); - // Ensure the directory exists - await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.mkdir(path.dirname(FILE_PATH), { recursive: true }); writeLock = writeLock.then(async () => { let data = []; try { - const file = await fs.readFile(filePath, "utf8"); - // Attempt to parse the file content + const file = await fs.readFile(FILE_PATH, "utf8"); 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); + console.error(ERRORS.PARSE_FAILURE, 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)); + await fs.writeFile(FILE_PATH, JSON.stringify(data, null, 2)); } catch (err) { - console.error("writeFile failed:", err); + console.error(ERRORS.WRITE_FAILURE, err); throw err; } } }); } catch (err) { - console.error("Failed to save email:", err); + console.error(ERRORS.SAVE_EMAIL_FAILURE, err); throw err; } return await writeLock; diff --git a/src/services/postsMenuService.js b/src/services/postsMenuService.js index 3ccad2d..c9966fe 100644 --- a/src/services/postsMenuService.js +++ b/src/services/postsMenuService.js @@ -1,4 +1,4 @@ -// src/services/postsMenuService.js (refactored) +// src/services/postsMenuService.js const { getAllPosts } = require("../utils/postFileUtils"); const { qualifyLink } = require("../utils/qualifyLinks"); diff --git a/src/services/rssFeedService.js b/src/services/rssFeedService.js index 00b93cd..c999a3a 100644 --- a/src/services/rssFeedService.js +++ b/src/services/rssFeedService.js @@ -1,22 +1,27 @@ // src/services/rssFeedService.js const RSS = require("rss"); const { getAllPosts } = require("../utils/postFileUtils"); +const { + FEED_TITLE, + FEED_DESCRIPTION, + FEED_LANGUAGE, +} = require("../constants/rssConstants"); async function generateRSSFeed(baseDir, siteUrl) { const allPosts = await getAllPosts(baseDir); const feed = new RSS({ - title: "My Blog", - description: "Latest posts from my blog", + title: FEED_TITLE, + description: FEED_DESCRIPTION, feed_url: `${siteUrl}/rss.xml`, site_url: siteUrl, - language: "en", + language: FEED_LANGUAGE, }); for (const post of allPosts) { feed.item({ title: post.title, - description: post.excerpt || "", // optional: add excerpt to post object + description: post.excerpt || "", url: `${siteUrl}${post.url}`, date: post.date, }); diff --git a/src/services/sitemapService.js b/src/services/sitemapService.js index 9d47214..14422cc 100644 --- a/src/services/sitemapService.js +++ b/src/services/sitemapService.js @@ -1,22 +1,27 @@ -// src/services/sitemapService.js (refactored) +// src/services/sitemapService.js const path = require("path"); const fs = require("fs").promises; const { getAllPosts } = require("../utils/postFileUtils"); +const { + STATIC_SITEMAP_PATH, + POSTS_PATH, + DEFAULT_CHANGEFREQ, + DEFAULT_PRIORITY, + BLOG_POST_CHANGEFREQ, + BLOG_POST_PRIORITY, +} = require("../constants/sitemapConstants"); class SitemapService { constructor() { - this.staticSitemapPath = path.resolve( - __dirname, - "../../content/sitemap.json" - ); - this.postsPath = path.join(__dirname, "../../content/posts"); + this.staticSitemapPath = path.resolve(__dirname, STATIC_SITEMAP_PATH); + this.postsPath = path.join(__dirname, POSTS_PATH); } async getStaticPages() { try { const data = await fs.readFile(this.staticSitemapPath, "utf-8"); return JSON.parse(data); - } catch (error) { + } catch { console.warn("Could not load static sitemap.json, using empty array"); return []; } @@ -30,8 +35,8 @@ lastmod: post.date ? new Date(post.date).toISOString().split("T")[0] : null, - changefreq: "monthly", - priority: "0.7", + changefreq: BLOG_POST_CHANGEFREQ, + priority: BLOG_POST_PRIORITY, })); } @@ -41,7 +46,6 @@ this.getBlogPostUrls(), ]); - // Add blog posts as a section in the sitemap const blogSection = { title: "Blog Posts", children: blogUrls.map((url) => ({ @@ -67,8 +71,8 @@ out.push({ loc: entry.loc, lastmod: entry.lastmod, - changefreq: entry.changefreq || "monthly", - priority: entry.priority || "0.5", + changefreq: entry.changefreq || DEFAULT_CHANGEFREQ, + priority: entry.priority || DEFAULT_PRIORITY, }); } if (Array.isArray(entry.children)) { diff --git a/src/utils/baseContext.js b/src/utils/baseContext.js index 7be3733..ca43b5a 100644 --- a/src/utils/baseContext.js +++ b/src/utils/baseContext.js @@ -7,28 +7,36 @@ const navLinks = require(path.join(__dirname, "../../content/navLinks.json")); const filterSecureLinks = require("../utils/filterSecureLinks"); +const getSiteTitle = (owner) => `${owner}'s Software Blog`; + +const POSTS_DIR = path.join(__dirname, "../../content/posts"); +const DEFAULT_CONTEXT = { + showSidebar: true, + showFooter: true, +}; + module.exports = async function getBaseContext( isAuthenticated, overrides = {} ) { const filteredNavLinks = filterSecureLinks(navLinks, isAuthenticated); const qualifiedNavLinks = qualifyNavLinks(filteredNavLinks); - const menu = await getPostsMenu(path.join(__dirname, "../../content/posts")); + const menu = await getPostsMenu(POSTS_DIR); const siteOwner = process.env.SITE_OWNER; - return Object.assign( - { - title: `${siteOwner}'s Software Blog`, - siteOwner, - originCountry: process.env.COUNTRY, - hCaptchaKey: process.env.HCAPTCHA_KEY, - navLinks: qualifiedNavLinks, - years: menu, - formatMonth, - baseUrl, - isAuthenticated, - showSidebar: true, - showFooter: true, - }, - overrides - ); + + const context = { + title: getSiteTitle(siteOwner), + siteOwner, + originCountry: process.env.COUNTRY, + hCaptchaKey: process.env.HCAPTCHA_KEY, + navLinks: qualifiedNavLinks, + years: menu, + formatMonth, + baseUrl, + isAuthenticated, + ...DEFAULT_CONTEXT, + ...overrides, + }; + + return context; }; diff --git a/src/utils/formLimiter.js b/src/utils/formLimiter.js index f28e0d9..d413bbc 100644 --- a/src/utils/formLimiter.js +++ b/src/utils/formLimiter.js @@ -1,8 +1,13 @@ const rateLimit = require("express-rate-limit"); +const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 5; +const RATE_LIMIT_MESSAGE = "Too many requests, please try again later."; + 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.", + windowMs: RATE_LIMIT_WINDOW_MS, + max: RATE_LIMIT_MAX_REQUESTS, + message: RATE_LIMIT_MESSAGE, }); + module.exports = formLimiter; diff --git a/src/utils/sendContactMail.js b/src/utils/sendContactMail.js index 6b53e2a..12ebf7c 100644 --- a/src/utils/sendContactMail.js +++ b/src/utils/sendContactMail.js @@ -1,7 +1,12 @@ -// src/utils/sendContactMail.js const transporter = require("./transporter"); -// Basic sanitization and validation functions +const MAIL_DOMAIN = process.env.MAIL_DOMAIN; +const MAIL_USER = process.env.MAIL_USER; +const DEFAULT_SUBJECT = "New Contact Form Submission"; +const MAX_MESSAGE_LENGTH = 2000; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + function sanitizeInput(input) { return String(input) .replace(/[\r\n<>]/g, "") @@ -9,37 +14,32 @@ } function isValidEmail(email) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + return EMAIL_REGEX.test(email); } function sendContactMail({ name, email, subject, message }) { - const { MAIL_DOMAIN: domain } = process.env; - - // Sanitize inputs const cleanName = sanitizeInput(name); const cleanEmail = sanitizeInput(email); - const cleanSubject = sanitizeInput(subject || "New Contact Form Submission"); + const cleanSubject = sanitizeInput(subject || DEFAULT_SUBJECT); 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, + if (cleanMessage.length > MAX_MESSAGE_LENGTH) { + throw new Error("Message too long"); + } + + const mailData = { + from: `"Contact Form" `, + to: MAIL_USER, replyTo: `"${cleanName}" <${cleanEmail}>`, subject: cleanSubject, text: cleanMessage, }; - // Optional: limit message length to prevent abuse - if (cleanMessage.length > 2000) { - throw new Error("Message too long"); - } - - return transporter.sendMail(data); + return transporter.sendMail(mailData); } module.exports = sendContactMail; diff --git a/src/utils/sendNewsletterSubscriptionMail.js b/src/utils/sendNewsletterSubscriptionMail.js index c8fae17..9265a01 100644 --- a/src/utils/sendNewsletterSubscriptionMail.js +++ b/src/utils/sendNewsletterSubscriptionMail.js @@ -1,19 +1,26 @@ -// src/utils/sendNewsletterSubscriptionMail.js const transporter = require("./transporter"); -const sendNewsletterSubscriptionMail = async function ({ email }) { - const { MAIL_DOMAIN: domain } = process.env; - const data = { - from: `"Newsletter" `, + +const MAIL_DOMAIN = process.env.MAIL_DOMAIN; +const MAIL_NEWSLETTER = process.env.MAIL_NEWSLETTER; + +const MAIL_SUBJECT = "New Newsletter Subscription"; +const MAIL_FROM = `Newsletter `; +const MAIL_TEXT_TEMPLATE = (email) => + `Please add this email to the newsletter list: ${MAIL_NEWSLETTER}`; + +async function sendNewsletterSubscriptionMail({ email }) { + const mailData = { + from: MAIL_FROM, to: email, - subject: "New Newsletter Subscription", - text: `Please add this email to the newsletter list: ${process.env.MAIL_NEWSLETTER}`, + subject: MAIL_SUBJECT, + text: MAIL_TEXT_TEMPLATE(email), }; + try { - const result = await transporter.sendMail(data); - return result; - } catch (e) { - console.log(e); + return await transporter.sendMail(mailData); + } catch (error) { + console.error(error); } -}; +} module.exports = sendNewsletterSubscriptionMail; diff --git a/src/utils/validateEmail.js b/src/utils/validateEmail.js index 5ae0b4f..249b95c 100644 --- a/src/utils/validateEmail.js +++ b/src/utils/validateEmail.js @@ -1,29 +1,33 @@ const validator = require("validator"); -// Email validation function +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: "Email is required" }; + if (!email || typeof email !== "string") { + return { valid: false, message: MESSAGES.REQUIRED }; } - - // Trim and normalize + email = email.trim().toLowerCase(); - - // Length check - if (email.length > 254) { - return { valid: false, message: "Email address is too long" }; + + if (email.length > MAX_EMAIL_LENGTH) { + return { valid: false, message: MESSAGES.TOO_LONG }; } - - // Basic validation - if (!validator.isEmail(email)) { - return { valid: false, message: "Please enter a valid email address" }; + + if ( + !validator.isEmail(email) || + email.includes("..") || + email.startsWith(".") || + email.endsWith(".") + ) { + return { valid: false, message: MESSAGES.INVALID }; } - - // Additional checks for suspicious patterns - if (email.includes('..') || email.startsWith('.') || email.endsWith('.')) { - return { valid: false, message: "Please enter a valid email address" }; - } - + return { valid: true, email }; };