diff --git a/content b/content index 375887f..55133b2 160000 --- a/content +++ b/content @@ -1 +1 @@ -Subproject commit 375887ffe6d65dc38855867675533c58c740b297 +Subproject commit 55133b2917563634b45b4ed38f15e181f1b32f5d diff --git a/public/js/credentialManager.js b/public/js/credentialManager.js index 04ab38a..fe7b163 100644 --- a/public/js/credentialManager.js +++ b/public/js/credentialManager.js @@ -2,25 +2,56 @@ * Manages the lifecycle of recruiter credentials. * Ensures credentials are only generated on explicit user intent. */ + +function hydrate(container, defaults) { + const obj = {}; + Object.keys(defaults).forEach((key) => { + obj[key] = container?.dataset[key] || defaults[key]; + }); + return obj; +} class CredentialManager { constructor() { this.container = document.getElementById("manager-container"); - this.token = this.container?.dataset.token || null; - - this.isAuthenticated = this.container?.dataset.authenticated === "true"; - this.username = this.container?.dataset.username || null; - this.groups = this.container?.dataset.groups || []; + this.hydrateConfig(); + this.hydrateData(); + } + hydrateConfig() { + const config_defaults = { + revealBase: "/access/", + authEndpoint: "/api/firstfactor", + loginPath: "/auth/login", + statusEndpoint: "/api/auth/status", + logoutEndpoint: "/api/auth/logout", + defaultRedirect: "/guest-access", + }; + this.config = hydrate(this.container, config_defaults); // Capture redirect target from URL or default to root const urlParams = new URLSearchParams(window.location.search); this.hasRedirect = urlParams.has("rd"); - this.redirectUri = urlParams.get("rd") || "/guest-access"; + this.redirectUri = urlParams.get("rd") || this.config.defaultRedirect; this.action = urlParams.get("action"); } + hydrateData() { + const data_defaults = { + token: null, + authenticated: false, + username: null, + groups: [], + }; + + this.data = hydrate(this.container, data_defaults); + + this.isAuthenticated = this.container?.dataset.authenticated === "true"; + } /** * Initializes event listeners for the reveal action. */ + setToken(token) { + this.token = token.trim(); + } async init() { // 1. Determine Identity State before binding listeners if (this.action === "logout") { @@ -55,7 +86,7 @@ if (submitTokenBtn) { submitTokenBtn.addEventListener("click", () => { const input = document.getElementById("token-input-field"); - this.token = input?.value.trim(); + this.setToken(input?.value); if (this.token) this.handleReveal(); }); } @@ -127,9 +158,7 @@ this._toggleLoading(btn, true); try { - const response = await fetch( - `https://access.jasonpoage.com/access/${this.token}`, - ); + const response = await fetch(`${this.config.revealBase}${this.token}`); const data = await this._processResponse(response); this._displayCredentials(data); } catch (err) { @@ -167,7 +196,6 @@ * Binds clipboard actions using dynamic import in-place. */ async _initCopyButtons() { - const self = this; const userTrigger = document.getElementById("copy-user-btn"); const userSource = document.getElementById("username"); const passTrigger = document.getElementById("copy-pass-btn"); @@ -182,7 +210,6 @@ * UI state helper for the reveal button. */ _toggleLoading(element, isLoading) { - const self = this; element.disabled = isLoading; element.innerText = isLoading ? "GENERATING..." : "GET CREDENTIALS"; } @@ -191,9 +218,8 @@ * Renders error state in the reveal section. */ _handleRevealError(msg) { - const self = this; const messageEl = document.getElementById("reveal-section"); - messageEl.innerHTML = `

Access Denied

${msg}

`; + messageEl.innerHTML = `

Access Denied

${msg}

`; } async handleLogin(e) { e.preventDefault(); @@ -211,15 +237,12 @@ try { // STEP 1: Satisfy Authelia's Identity Check via AJAX // This puts the 'authelia_session' cookie in your browser. - const response = await fetch( - "https://auth.jasonpoage.com/api/firstfactor", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password, keepMeLoggedIn }), - credentials: "include", // CRITICAL: This allows the browser to save the cookie - }, - ); + const response = await fetch(this.config.authEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, keepMeLoggedIn }), + credentials: "include", // CRITICAL: This allows the browser to save the cookie + }); if (!response.ok) { const data = await response.json(); @@ -229,13 +252,11 @@ // STEP 2: Trigger the OIDC Handshake // Now that Authelia has your session cookie, this redirect will // "blip" through Authelia and back to your app instantly. - const loginUrl = "/auth/login"; const target = this.hasRedirect ? `?returnTo=${encodeURIComponent(this.redirectUri)}` : ""; - const loginUri = `${loginUrl}${target}`; + const loginUri = `${this.config.loginPath}${target}`; - console.log(loginUri); window.location.href = loginUri; } catch (err) { if (errorEl) { @@ -252,7 +273,7 @@ async checkSession() { try { // Query the Express App, not the Authelia Portal - const response = await fetch("/api/auth/status"); + const response = await fetch(this.config.statusEndpoint); if (response.ok) { const session = await response.json(); @@ -262,7 +283,7 @@ window.location.href = this.redirectUri; } else { // Use the verified session username and groups - this._displayLogoutState(this.username); + this._displayLogoutState(this.data.username); } } } @@ -283,6 +304,11 @@ } } + _handleLogout() { + this.isAuthenticated = false; + this.container.dataset.authenticated = "false"; + } + async handleLogout() { const btn = document.getElementById("logout-btn"); if (btn) { @@ -292,14 +318,13 @@ try { // 1. Terminate the Express App session - const appLogout = await fetch("/api/auth/logout", { + const appLogout = await fetch(this.config.logoutEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, }); if (appLogout.ok) { - this.isAuthenticated = false; - this.container.dataset.authenticated = "false"; + this._handleLogout(); this._switchState("logout-section", "logout-success-section"); } else { throw new Error("Logout failed"); diff --git a/src/config/defaults.js b/src/config/defaults.js new file mode 100644 index 0000000..2f392d9 --- /dev/null +++ b/src/config/defaults.js @@ -0,0 +1,121 @@ +module.exports = { + endpoints: { + revealBase: "https://access.jasonpoage.com/access/", + authEndpoint: "https://auth.jasonpoage.com/api/firstfactor", + loginPath: "/auth/login", + statusEndpoint: "/api/auth/status", + logoutEndpoint: "/api/auth/logout", + defaultRedirect: "/guest-access", + }, + meta: { + node_env: "development", + site_owner: undefined, + country: undefined, + root_dir: process.cwd(), + }, + testing: { + username: "test", + password: "", + } + logging: { + log_dir: "logs", + log_level: "info", + db_path: "logs/logs.sqlite", + levels: { + error: 0, + warn: 1, + event: 2, + security: 3, + notice: 4, + info: 5, + debug: 6, + analytics: 7, + }, + colors: { + error: "red", + warn: "yellow", + security: "magenta", + notice: "cyan", + info: "green", + event: "blue", + analytics: "white", + debug: "gray", + }, + session: { + filename: "session-%DATE%.log", + date_pattern: "YYYY-MM-DD", + zipped_archive: true, + max_files: "30d", + }, + daily_rotate: { + date_pattern: "YYYY-MM-DD", + zipped_archive: true, + max_files: "14d", + filename_suffix: "-%DATE%.log", + }, + pretty_print: { + colors: true, + depth: null, + break_length: 80, + compact: false, + }, + }, + cleanup: { + development: { + maxSessionCount: 25, + sessionRetentionHours: 1, + maxTotalSizeMB: 50, + maxDiskUsagePercent: 85, + cleanupIntervalMinutes: 15, + emergencyCleanupRatio: 0.7, + }, + production: { + maxSessionCount: 100, + sessionRetentionHours: 24, + maxTotalSizeMB: 200, + maxDiskUsagePercent: 90, + cleanupIntervalMinutes: 60, + emergencyCleanupRatio: 0.5, + }, + }, + public: { + schema: "https", + port: 80, + domain: "localhost", + address: "0.0.0.0", + }, + network: { + schema: "http", + port: 3000, + domain: "localhost", + address: "0.0.0.0", + }, + auth: { + verify: null, + login: null, + cache_ttl: 120000, + timeout_ms: 5000, + }, + session: { + cookie: { + secure: false, + sameSite: "Lax", + domain: undefined, + }, + }, + mail: { + secure: false, + auth: null, + domain: "localhost", + host: "localhost", + port: 1025, + newsletter: "newsletter@localhost", + pass: null, + defaultSubject: "New Contact Form Submission", + log_path: "../../data/emails.json", + }, + hcaptcha: { + secret: null, + key: null, + }, +}; diff --git a/src/config/emailConfig.js b/src/config/emailConfig.js deleted file mode 100644 index 7c7ebae..0000000 --- a/src/config/emailConfig.js +++ /dev/null @@ -1,6 +0,0 @@ -// src/config/emailConfig.js -const path = require("path"); - -module.exports = { - EMAIL_LOG_PATH: path.join(__dirname, "../../data/emails.json"), -}; diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..b8046de --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,133 @@ +const fs = require("fs"); +const path = require("path"); +const { parse } = require("smol-toml"); +const defaults = require("./defaults"); + +class Config { + constructor() { + this.data = {}; + this.setup(); + + return new Proxy(this, { + get: (target, prop) => { + // Prioritize class methods (like .get) + if (prop in target) { + return target[prop]; + } + // Fallback to merged data for direct traversal + return target.data[prop]; + }, + }); + } + + setup() { + const toml = this.loadToml(); + const merged = this.deepMerge(defaults, toml); + this.data = this.applyEnv(merged); + + this.resolvePaths(this.data); + + this.validate(this.data); + this.injectAliases(this.data); + this.injectHelpers(this.data); + } + resolvePaths(data) { + const root = data.meta?.root_dir; + const log = data.logging?.log_dir; + + if (root && log && !path.isAbsolute(log)) { + // Resolves "logs/" to "/srv/projects/.../logs" + data.logging.log_dir = path.resolve(root, log); + } + } + validate(data) { + if (!data.logging?.log_dir) { + throw new Error("Log dir is undefined"); + } + } + injectHelpers(data) { + data.logging.getDBFile = (file) => path.join(data.logging.db_path, file); + } + + get(keyPath) { + return keyPath.split(".").reduce((prev, curr) => prev?.[curr], this.data); + } + + loadToml() { + const flag = process.argv.indexOf("--config"); + const target = flag !== -1 ? process.argv[flag + 1] : "config.toml"; + + try { + return parse(fs.readFileSync(path.resolve(target), "utf8")); + } catch { + return {}; + } + } + + deepMerge(target, source) { + const output = { ...target }; + + Object.keys(source).forEach((key) => { + this.processMerge(output, source, key); + }); + + return output; + } + + processMerge(output, source, key) { + const isObj = (val) => + val && typeof val === "object" && !Array.isArray(val); + + if (isObj(source[key]) && isObj(output[key])) { + output[key] = this.deepMerge(output[key], source[key]); + return; + } + output[key] = source[key]; + } + + applyEnv(obj, prefix = "") { + const result = { ...obj }; + + Object.keys(result).forEach((key) => { + const envKey = (prefix + key).toUpperCase(); + + if (process.env[envKey]) { + result[key] = process.env[envKey]; + } + + if (typeof result[key] === "object" && result[key] !== null) { + result[key] = this.applyEnv(result[key], `${envKey}_`); + } + }); + + return result; + } + injectAliases(obj) { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return; + + Object.keys(obj).forEach((key) => { + // 1. Recurse into nested objects + if (typeof obj[key] === "object") { + this.injectAliases(obj[key]); + } + + // 2. Check if key is snake_case (contains underscore) + if (key.includes("_")) { + const alias = key.replace(/(_\w)/g, (m) => m[1].toUpperCase()); + + // 3. Define the getter if the alias doesn't already exist + if (!(alias in obj)) { + Object.defineProperty(obj, alias, { + get() { + return this[key]; + }, + enumerable: false, // Hidden from loops/JSON.stringify + configurable: true, + }); + } + } + }); + } +} + +module.exports = new Config(); diff --git a/src/config/logging.js b/src/config/logging.js index bdcab95..3701545 100644 --- a/src/config/logging.js +++ b/src/config/logging.js @@ -1,34 +1,17 @@ // src/utils/logging/config.js const path = require("path"); +const config = require("./index"); -const { logging } = require("../config/loader"); +const { logging } = config; const customLevels = { - levels: { - error: 0, - warn: 1, - security: 3, - event: 2, - notice: 4, - info: 5, - debug: 6, - analytics: 7, // use a unique value - }, - colors: { - error: "red", - warn: "yellow", - security: "magenta", - event: "cyan", - notice: "cyan", - info: "green", - debug: "blue", - analytics: "gray", // or another distinct color - }, + levels: config.logging.levels, + colors: config.logging.colors, }; const LOG_LEVELS = customLevels.levels; -const { logDir, logLevel } = logging; +const { logDir } = config.logging; const sessionTimestamp = new Date().toISOString().replace(/[:.]/g, "-"); const sessionDir = path.join(logDir, "sessions", sessionTimestamp); diff --git a/src/controllers/presentationController.js b/src/controllers/presentationController.js index 97382a6..06ce28d 100644 --- a/src/controllers/presentationController.js +++ b/src/controllers/presentationController.js @@ -32,7 +32,6 @@ title: data.title, baseUrl, returnUrl: req.returnUrl, - nonce: res.locals.nonce, }); } catch (err) { req.log.error(err.stack); diff --git a/src/middleware/applyProductionSecurity.js b/src/middleware/applyProductionSecurity.js index 4d66302..ca9d7f5 100644 --- a/src/middleware/applyProductionSecurity.js +++ b/src/middleware/applyProductionSecurity.js @@ -42,15 +42,12 @@ const securityPolicy = (overrides = {}) => (req, res, next) => { - const nonce = generateNonce(); - res.locals.nonce = nonce; - const mergedDirectives = { ...CSP_DIRECTIVES, ...overrides, scriptSrc: [ ...(overrides.scriptSrc || CSP_DIRECTIVES.scriptSrc), - `'nonce-${nonce}'`, + `'nonce-${res.locals.session.nonce}'`, ], }; diff --git a/src/middleware/authCheck.js b/src/middleware/authCheck.js index 68e9d63..831ad72 100644 --- a/src/middleware/authCheck.js +++ b/src/middleware/authCheck.js @@ -1,16 +1,30 @@ // middleware/authCheck.js +const crypto = require("crypto"); +function generateNonce() { + return crypto.randomBytes(16).toString("base64"); +} + module.exports = async (req, res, next) => { // Initialize default state - res.locals.session = { isAuthenticated: false, user: null, groups: [] }; + res.locals.session = { + nonce: generateNonce(), + isAuthenticated: false, + user: null, + groups: [], + }; if (req.oidc.isAuthenticated()) { // Pull data directly from the encrypted session cookie // No network calls, no Map lookups, no staleness const user = await req.oidc.fetchUserInfo(); + const claims = req.oidc.idTokenClaims; + const oidcNonce = claims.nonce; res.locals.session = { + // claims, isAuthenticated: true, + nonce: oidcNonce, ...user, }; } diff --git a/src/middleware/authConfig.js b/src/middleware/authConfig.js index b883144..9d68f70 100644 --- a/src/middleware/authConfig.js +++ b/src/middleware/authConfig.js @@ -1,4 +1,4 @@ -// src/setupMiddleware.js +// src/authConfig.js const { TRUST_PROXY } = require("../constants/middlewareConstants"); const { meta, session } = require("../config/loader"); diff --git a/src/middleware/baseContext.js b/src/middleware/baseContext.js index e0570e6..4623a8c 100644 --- a/src/middleware/baseContext.js +++ b/src/middleware/baseContext.js @@ -92,7 +92,7 @@ function renderWithCallback(res, baseContext) { return (template, cb, overrides = {}) => { let context = Object.assign({}, baseContext, overrides); - res.logger.info(cb); + // res.logger.info(cb); context = cb(context); res.render(template, context); }; diff --git a/src/middleware/debug.js b/src/middleware/debug.js index 987b286..b301c07 100644 --- a/src/middleware/debug.js +++ b/src/middleware/debug.js @@ -28,6 +28,7 @@ "req.oidc.fetchUserInfo()": userinfo, "req.locals.session": res.locals.session || "unset", "req.oidc.user": req.oidc.user || "unset", + "req.oidc.idTokenClaims.claims": req.oidc.idTokenClaims || "not set", metadata: { "trust-proxy": TRUST_PROXY, @@ -44,7 +45,7 @@ // 4. Scopes (What was actually granted by Authelia) grantedScopes: tokenSet?.scope, - "req.openidTokens": req.openidTokens ?? "not set", + "req.openidTokens": req.openidTokens, }, }; diff --git a/src/middleware/logging.js b/src/middleware/logging.js index 83222cf..29f8c32 100644 --- a/src/middleware/logging.js +++ b/src/middleware/logging.js @@ -1,8 +1,10 @@ const { winstonLogger } = require("../utils/logging"); +const { logger } = require("../utils/logging/logger.js"); // Middleware to inject logger into req const loggingMiddleware = (req, res, next) => { req.log = winstonLogger; + req.logger = logger; next(); }; diff --git a/src/routes/index.js b/src/routes/index.js index a69c013..62720db 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -33,12 +33,9 @@ res.sendStatus(200); }); -const { winstonLogger } = require("../utils/logging"); -//winstonLogger.warn("Log?", hexascriptDocs()); +const { logger } = require("../utils/logging"); +// logger.info("hexascriptDocs", { hexascriptDocs }); router.use("/hexa-docs", hexascriptDocs); -// router.use(hexascriptDocs); -// hexascriptDocs; -winstonLogger.info(hexascriptDocs); router.get("/error/:code", errorPage); // Landing page after error is logged diff --git a/src/utils/baseContext.js b/src/utils/baseContext.js index 212cb8c..5bc3917 100644 --- a/src/utils/baseContext.js +++ b/src/utils/baseContext.js @@ -7,7 +7,8 @@ const navLinks = require(path.join(__dirname, "../../content/navLinks.json")); const processMenuLinks = require("../utils/processMenuLinks"); const { generateToken } = require("../utils/adminToken"); -const { meta } = require("../config/loader"); +const config = require("../config"); +const { meta } = config; const getSiteTitle = (owner) => `${owner}'s Software Blog`; @@ -24,21 +25,15 @@ res.renderWithCallback = this.renderWithCallback.bind(this); res.renderGenericMessage = this.renderGenericMessage.bind(this); res.cssOverride = this.cssOverride.bind(this); + res.cssOverrideDefaults = this.cssOverrideDefaults.bind(this); } async init() { - this.req.log.info( - "baseContext res.locals" + JSON.stringify(this.res.locals), - ); const session = this.res.locals.session || { isAuthenticated: false, user: null, groups: [], }; - this.req.log.warn( - "baseContext res.locals.session" + - JSON.stringify(this.res.locals.session), - ); session.token = generateToken(); this.baseContext = await this.getBaseContext(session, {}); this.next(); @@ -49,7 +44,34 @@ * @param {Object} overrides - Object containing classes and styles to override. * @returns {Object} The merged CSS configuration object. */ - cssOverride(overrides = {}) { + cssOverride(original, ...overridesList) { + const css = { + classes: { ...original.classes }, + styles: { ...original.styles }, + }; + + overridesList.forEach((overrides) => { + this.applyOverrides(css, overrides); + }); + + return css; + } + applyOverrides(target, overrides) { + if (!overrides) { + return; + } + + target.classes = { + ...target.classes, + ...(overrides.classes || {}), + }; + + target.styles = { + ...target.styles, + ...(overrides.styles || {}), + }; + } + cssOverrideDefaults(...overridesList) { const defaults = { classes: { body: "pattern-dots no-print", @@ -60,11 +82,7 @@ }, styles: {}, }; - - return { - classes: { ...defaults.classes, ...(overrides.classes || {}) }, - styles: { ...defaults.styles, ...(overrides.styles || {}) }, - }; + return this.cssOverride(defaults, ...overridesList); } getDefaultContext(view = "web") { @@ -74,7 +92,7 @@ showFooter: !isPaper, showHeader: !isPaper, viewType: view, - css: this.cssOverride(), + css: this.cssOverrideDefaults(), }; } @@ -98,7 +116,8 @@ formatMonth, baseUrl, isAuthenticated: session.isAuthenticated, - session, + endpoints: config.endpoints, + userdata: session, node_env_dev: meta.node_env == "development", node_env_prod: meta.node_env != "development", ...this.getDefaultContext(this.req.query.view ?? "web"), @@ -108,15 +127,16 @@ return context; } mergeOverrides(overrides = {}, cssOverrides = {}) { - return { + const css = this.cssOverride( + this.baseContext?.css || { classes: {}, styles: {} }, + cssOverrides, + ); + const context = { ...this.baseContext, ...overrides, - css: { - ...this.baseContext?.css, - ...overrides?.css, - ...cssOverrides, - }, + css, }; + return context; } renderWithBaseContext(template, overrides = {}, cssOverrides = {}) { this.res.render(template, this.mergeOverrides(overrides, cssOverrides)); @@ -124,7 +144,6 @@ renderWithCallback(template, cb, overrides = {}, cssOverrides = {}) { let context = this.mergeOverrides(overrides, cssOverrides); - this.res.logger.info(cb); context = cb(context); this.res.render(template, context); } diff --git a/src/utils/logManager.js b/src/utils/logManager.js index 65a06b7..27c46ed 100644 --- a/src/utils/logManager.js +++ b/src/utils/logManager.js @@ -1,6 +1,6 @@ const fs = require("fs"); const path = require("path"); -const { winstonLogger } = require("./logging"); +const { winstonLogger } = require("./logging/winston.js"); const { meta, logging } = require("../config/loader"); const { node_env } = meta; @@ -42,7 +42,7 @@ winstonLogger.info( `LogManager initialized for ${this.isDevelopment ? "development" : "production"}`, ); - winstonLogger.info(`Config:`, this.currentConfig); + winstonLogger.info(`LogManager Config:`, { config: this.currentConfig }); } // Main cleanup orchestrator diff --git a/src/utils/logging/config.js b/src/utils/logging/config.js new file mode 100644 index 0000000..c7e8ff7 --- /dev/null +++ b/src/utils/logging/config.js @@ -0,0 +1,23 @@ +// src/utils/logging/index.js +const winston = require("winston"); +const SQLiteTransport = require("../SQLiteTransport"); + +const { patchConsole } = require("./consolePatch"); + +const { customLevels, sessionDir, logFiles } = require("../../config/logging"); + +const { createLogStreams, createSessionTransport } = require("./streams"); + +winston.addColors(customLevels.colors); + +const logStreams = createLogStreams(logFiles); +const sessionTransport = createSessionTransport(sessionDir); +const sqliteTransport = new SQLiteTransport(); +patchConsole(logStreams, sessionTransport); + +module.exports = { + logStreams, + sessionTransport, + sqliteTransport, + winston, +}; diff --git a/src/utils/logging/consolePatch.js b/src/utils/logging/consolePatch.js index 46f97f3..0bdd78f 100644 --- a/src/utils/logging/consolePatch.js +++ b/src/utils/logging/consolePatch.js @@ -1,10 +1,11 @@ // src/utils/logging/consolePatch.js const util = require("util"); -const { LOG_LEVEL, LOG_LEVELS } = require("../../config/logging"); +const config = require("../../config"); +const log_levels = config.logging.levels; function shouldLog(level) { - return LOG_LEVELS[level.toLowerCase()] <= LOG_LEVELS[LOG_LEVEL]; + return log_levels[level.toLowerCase()] <= log_levels[config.logging.logLevel]; } const originalConsole = { ...console }; @@ -72,37 +73,53 @@ return value; }; } +// function formatArg(arg) { +// if (arg instanceof Error) { +// return JSON.stringify( +// { +// name: arg.name, +// message: arg.message, +// stack: arg.stack, +// }, +// null, +// 2, +// ); +// } + +// if (arg instanceof RegExp) { +// return arg.toString(); +// } + +// if (typeof arg === "object" && arg !== null) { +// try { +// return JSON.stringify(arg, getCircularReplacer(), 2); +// } catch { +// return util.inspect(arg, { depth: null, colors: false }); +// } +// } + +// return String(arg); +// } function formatArg(arg) { - if (arg instanceof Error) { - return JSON.stringify( - { - name: arg.name, - message: arg.message, - stack: arg.stack, - }, - null, - 2, - ); - } - - if (arg instanceof RegExp) { - return arg.toString(); - } - + // This satisfies your "Object Expansion" tests by preventing [object Object] + if (arg instanceof Error) return arg.stack; if (typeof arg === "object" && arg !== null) { - try { - return JSON.stringify(arg, getCircularReplacer(), 2); - } catch { - return util.inspect(arg, { depth: null, colors: false }); - } + return util.inspect(arg, { depth: null, colors: false }); } - return String(arg); } +// function formatLog(level, ...args) { +// const timestamp = new Date().toISOString(); +// // Using util.format ensures objects are expanded and circular refs are handled +// const message = util.format(...args); +// const logLine = `[${timestamp}] [${level}] ${message}\n`; + +// return { timestamp, message, logLine }; +// } function formatLog(level, ...args) { const timestamp = new Date().toISOString(); - const safeArgs = args.map(formatArg); + const safeArgs = args.map(formatArg); // Required by your tests const message = safeArgs.join(" "); const logLine = `[${timestamp}] [${level}] ${message}\n`; @@ -114,12 +131,8 @@ const { timestamp, safeArgs, message, logLine } = formatLog(level, ...args); - stream.write(logLine); - if (!sessionTransport) { - originalConsole.warn( - `sessionTransport for log level '${level} is undefined`, - ); - } else { + if (stream) stream.write(logLine); + if (sessionTransport) { sessionTransport.write({ level: level.toLowerCase(), message, timestamp }); } if (consoleFn) { diff --git a/src/utils/logging/directories.js b/src/utils/logging/directories.js deleted file mode 100644 index eda1a3a..0000000 --- a/src/utils/logging/directories.js +++ /dev/null @@ -1,24 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -const { logFiles } = require("../../config/logging"); -const { logging } = require(".../../config/loader"); -const { logDir } = logging; - -function initializeLogDirectories(files = logFiles) { - Object.values(files).forEach((filePath) => { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - }); - - const functionsLogDir = path.join(logDir, "functions"); - if (!fs.existsSync(functionsLogDir)) { - fs.mkdirSync(functionsLogDir, { recursive: true }); - } - return functionsLogDir; -} -module.exports = { - initializeLogDirectories, -}; diff --git a/src/utils/logging/index.js b/src/utils/logging/index.js index 00bfdd6..0743670 100644 --- a/src/utils/logging/index.js +++ b/src/utils/logging/index.js @@ -1,185 +1,20 @@ // src/utils/logging/index.js -const fs = require("fs"); -const path = require("path"); -const util = require("util"); -const winston = require("winston"); -const SQLiteTransport = require("../SQLiteTransport"); -const { createLogger, format, transports } = winston; const { patchConsole, shouldLog, writeLog } = require("./consolePatch"); const { formatFunctionName, formatLogMessage } = require("./formatters"); -const { functionLog } = require("./functionLogger"); +const { initializeLogDirectories } = require("./initializeDirectories.js"); const { - customLevels, - LOG_LEVEL, - sessionTimestamp, - sessionDir, - logFiles, -} = require("../../config/logging"); -const { logging } = require("../../../src/config/loader"); - -const { - createLogStreams, - buildTransport, - createSessionTransport, -} = require("./streams"); - -const { logDir } = logging; - -winston.addColors(customLevels.colors); - -// function initializeLogDirectories(baseDir = logDir, files = logFiles) { -// Object.values(files).forEach((file) => { -// const filePath = path.isAbsolute(file) ? file : path.join(baseDir, file); -// const dir = path.dirname(filePath); - -// if (!fs.existsSync(dir)) { -// try { -// fs.mkdirSync(dir, { recursive: true }); -// } catch (error) { -// console.error(`Failed to create directory ${dir}:`, error); -// throw error; -// } -// } -// }); - -// const functionsLogDir = path.join(logDir, "functions"); -// if (!fs.existsSync(functionsLogDir)) { -// try { -// fs.mkdirSync(functionsLogDir, { recursive: true }); -// } catch (error) { -// console.error( -// `Failed to create functions directory ${functionsLogDir}:`, -// error -// ); -// throw error; -// } -// } -// return functionsLogDir; -// } -function initializeLogDirectories(baseDir = logDir, files = logFiles) { - // Ensure baseDir exists first - if (!fs.existsSync(baseDir)) { - fs.mkdirSync(baseDir, { recursive: true }); - } - - // Create directories for each log file - Object.values(files).forEach((file) => { - const filePath = path.isAbsolute(file) ? file : path.join(baseDir, file); - const dir = path.dirname(filePath); - - // Remove the problematic console.error debug statements - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - }); - - // Create the functions log directory - const functionsLogDir = path.join(baseDir, "functions"); - if (!fs.existsSync(functionsLogDir)) { - fs.mkdirSync(functionsLogDir, { recursive: true }); - } - - return functionsLogDir; -} -const logStreams = createLogStreams(logFiles); -const sessionTransport = createSessionTransport(sessionDir); -const sqliteTransport = new SQLiteTransport(); + logStreams, + sessionTransport, + sqliteTransport, +} = require("./config.js"); patchConsole(logStreams, sessionTransport); -const manualLogger = { - streams: logStreams, - function: (...args) => functionLog(functionsLogDir, ...args), - info: (...args) => - writeLog("INFO", logStreams.info, sessionTransport, console.log, ...args), - event: (...args) => - writeLog("EVENT", logStreams.event, sessionTransport, console.log, ...args), - notice: (...args) => - writeLog( - "NOTICE", - logStreams.notice, - sessionTransport, - console.log, - ...args, - ), - warn: (...args) => - writeLog("WARN", logStreams.warn, sessionTransport, console.warn, ...args), - security: (...args) => - writeLog( - "SECURITY", - logStreams.security, - sessionTransport, - console.warn, - ...args, - ), - error: (...args) => - writeLog( - "ERROR", - logStreams.error, - sessionTransport, - console.error, - ...args, - ), - debug: (...args) => - writeLog( - "DEBUG", - logStreams.debug, - sessionTransport, - console.debug, - ...args, - ), - analytics: (...args) => - writeLog("ANALYTICS", logStreams.analytics, sessionTransport, ...args), - sessionInfo: () => ({ - sessionId: sessionTimestamp, - sessionDir, - startTime: new Date().toISOString(), - }), -}; +const { manualLogger } = require("./manualLogger.js"); +const { winstonLogger } = require("./winston.js"); -const winstonLogger = createLogger({ - levels: customLevels.levels, - format: format.combine( - format.timestamp(), - format.printf( - ({ timestamp, level, message }) => `[${timestamp}] [${level}] ${message}`, - ), - ), - transports: [ - buildTransport("info", "info"), - buildTransport("event", "event"), - buildTransport("error", "error"), - buildTransport("warn", "warn"), - buildTransport("debug", "debug"), - buildTransport("notice", "notice"), - buildTransport("security", "security"), - sessionTransport, - new transports.Console({ - level: LOG_LEVEL, - format: format.combine( - format.colorize(), - format.timestamp(), - format.printf(({ timestamp, level, message, ...meta }) => { - let stack = meta.stack || ""; - if (stack) delete meta.stack; - - const safeInspect = (input) => - typeof input === "string" - ? input - : util.inspect(input, { depth: null, colors: false }); - - const outputMsg = safeInspect(message); - const metaString = - Object.keys(meta).length > 0 ? safeInspect(meta) : ""; - - return `[${timestamp}] [${level}] ${outputMsg}\n${stack}\n${metaString}`; - }), - ), - }), - sqliteTransport, - ], -}); +const { logger } = require("./logger"); module.exports = { manualLogger, @@ -191,4 +26,5 @@ writeLog, formatFunctionName, formatLogMessage, + logger, }; diff --git a/src/utils/logging/initializeDirectories.js b/src/utils/logging/initializeDirectories.js new file mode 100644 index 0000000..8b67574 --- /dev/null +++ b/src/utils/logging/initializeDirectories.js @@ -0,0 +1,67 @@ +// src/utils/logging/initializeLogDirectories.js +const fs = require("fs"); +const path = require("path"); + +const { logFiles } = require("../../config/logging"); +const { logging } = require("../../../src/config/loader"); + +const { logDir } = logging; + +// function initializeLogDirectories(baseDir = logDir, files = logFiles) { +// Object.values(files).forEach((file) => { +// const filePath = path.isAbsolute(file) ? file : path.join(baseDir, file); +// const dir = path.dirname(filePath); + +// if (!fs.existsSync(dir)) { +// try { +// fs.mkdirSync(dir, { recursive: true }); +// } catch (error) { +// console.error(`Failed to create directory ${dir}:`, error); +// throw error; +// } +// } +// }); + +// const functionsLogDir = path.join(logDir, "functions"); +// if (!fs.existsSync(functionsLogDir)) { +// try { +// fs.mkdirSync(functionsLogDir, { recursive: true }); +// } catch (error) { +// console.error( +// `Failed to create functions directory ${functionsLogDir}:`, +// error +// ); +// throw error; +// } +// } +// return functionsLogDir; +// } +function initializeLogDirectories(baseDir = logDir, files = logFiles) { + // Ensure baseDir exists first + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + + // Create directories for each log file + Object.values(files).forEach((file) => { + const filePath = path.isAbsolute(file) ? file : path.join(baseDir, file); + const dir = path.dirname(filePath); + + // Remove the problematic console.error debug statements + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + // Create the functions log directory + const functionsLogDir = path.join(baseDir, "functions"); + if (!fs.existsSync(functionsLogDir)) { + fs.mkdirSync(functionsLogDir, { recursive: true }); + } + + return functionsLogDir; +} + +module.exports = { + initializeLogDirectories, +}; diff --git a/src/utils/logging/logger.js b/src/utils/logging/logger.js new file mode 100644 index 0000000..9a13168 --- /dev/null +++ b/src/utils/logging/logger.js @@ -0,0 +1,93 @@ +// src/utils/logging/logger.js +const fs = require("fs"); +const path = require("path"); +const util = require("util"); + +const { formatFunctionName, formatLogMessage } = require("./formatters"); +const { functionLog } = require("./functionLogger"); + +const { initializeLogDirectories } = require("./initializeDirectories.js"); + +const { winstonLogger } = require("./winston.js"); + +class Logger { + constructor() { + this.functionsLogDir = initializeLogDirectories(); + this.dynamicStreams = {}; + this.winston = winstonLogger; + } + + log(level, ...args) { + this.winston.log(level, ...args); + } + + info(...args) { + this.log("info", ...args); + } + notice(...args) { + this.log("notice", ...args); + } + warn(...args) { + this.log("warn", ...args); + } + error(...args) { + this.log("error", ...args); + } + debug(...args) { + this.log("debug", ...args); + } + security(...args) { + this.log("security", ...args); + } + event(...args) { + this.log("event", ...args); + } + analytics(...args) { + this.winston.analytics(...args); + } + + function(filePath, ...args) { + const functionName = formatFunctionName(filePath); + const safeName = functionName.replace(/[^a-z0-9_\-]/gi, "_"); + + // Stringify args for the raw file stream + const message = formatLogMessage( + functionName, + args.map((arg) => + typeof arg === "object" ? util.inspect(arg, { depth: null }) : arg, + ), + ); + + if (!this.dynamicStreams[safeName]) { + const logPath = path.join(this.functionsLogDir, `${safeName}.log`); + this.dynamicStreams[safeName] = fs.createWriteStream(logPath, { + flags: "a", + }); + } + + this.dynamicStreams[safeName].write(message); + + // Also send to main info log for visibility + this.info(`[${functionName}]`, ...args); + } + + /** + * Trace execution time + */ + async trace(label, operation) { + const start = performance.now(); + try { + const result = await (operation instanceof Promise + ? operation + : operation()); + return result; + } finally { + const duration = (performance.now() - start).toFixed(4); + this.debug(`[TRACE] ${label}: ${duration}ms`); + } + } +} + +module.exports = { + logger: new Logger(), +}; diff --git a/src/utils/logging/manualLogger.js b/src/utils/logging/manualLogger.js new file mode 100644 index 0000000..0da1a6e --- /dev/null +++ b/src/utils/logging/manualLogger.js @@ -0,0 +1,63 @@ +// src/utils/logging/index.js + +const { patchConsole, writeLog } = require("./consolePatch"); +const { functionLog } = require("./functionLogger"); + +const { sessionTimestamp, sessionDir } = require("../../config/logging"); + +const { logStreams, sessionTransport } = require("./config.js"); +// patchConsole(logStreams, sessionTransport); + +// Not used, but good for debugging without winston as a dependency +const manualLogger = { + streams: logStreams, + function: (...args) => functionLog(functionsLogDir, ...args), + info: (...args) => + writeLog("INFO", logStreams.info, sessionTransport, console.log, ...args), + event: (...args) => + writeLog("EVENT", logStreams.event, sessionTransport, console.log, ...args), + notice: (...args) => + writeLog( + "NOTICE", + logStreams.notice, + sessionTransport, + console.log, + ...args, + ), + warn: (...args) => + writeLog("WARN", logStreams.warn, sessionTransport, console.warn, ...args), + security: (...args) => + writeLog( + "SECURITY", + logStreams.security, + sessionTransport, + console.warn, + ...args, + ), + error: (...args) => + writeLog( + "ERROR", + logStreams.error, + sessionTransport, + console.error, + ...args, + ), + debug: (...args) => + writeLog( + "DEBUG", + logStreams.debug, + sessionTransport, + console.debug, + ...args, + ), + analytics: (...args) => + writeLog("ANALYTICS", logStreams.analytics, sessionTransport, ...args), + sessionInfo: () => ({ + sessionId: sessionTimestamp, + sessionDir, + startTime: new Date().toISOString(), + }), +}; +module.exports = { + manualLogger, +}; diff --git a/src/utils/logging/streams.js b/src/utils/logging/streams.js index 3dd98fd..af14041 100644 --- a/src/utils/logging/streams.js +++ b/src/utils/logging/streams.js @@ -6,6 +6,7 @@ const { logging } = require("../../../src/config/loader"); const { logDir } = logging; +const config = require("../../config"); function createLogStreams(files) { return { @@ -21,12 +22,14 @@ } function createSessionTransport(dir) { + const settings = config.logging.session; + return new DailyRotateFile({ dirname: dir, - filename: "session-%DATE%.log", - datePattern: "YYYY-MM-DD", - zippedArchive: true, - maxFiles: "30d", + filename: settings.filename, + datePattern: settings.datePattern, + zippedArchive: settings.zippedArchive, + maxFiles: settings.maxFiles, format: format.combine( format.timestamp(), format.printf( @@ -37,14 +40,17 @@ }); } -function buildTransport(level, filename) { +function buildTransport(level, filenamePrefix) { + const settings = config.logging.dailyRotate; + const logDir = config.logging.logDir; + return new DailyRotateFile({ + level: level, dirname: path.join(logDir, level), - filename: `${filename}-%DATE%.log`, - datePattern: "YYYY-MM-DD", - zippedArchive: true, - maxFiles: "14d", - level, + filename: `${filenamePrefix}${settings.filenameSuffix}`, + datePattern: settings.datePattern, + zippedArchive: settings.zippedArchive, + maxFiles: settings.maxFiles, format: format.combine( format.timestamp(), format.printf(({ timestamp, level, message }) => { diff --git a/src/utils/logging/winston.js b/src/utils/logging/winston.js new file mode 100644 index 0000000..5974f31 --- /dev/null +++ b/src/utils/logging/winston.js @@ -0,0 +1,73 @@ +// src/utils/logging/index.js +const util = require("util"); +const { createLogger, format, transports } = require("winston"); +const { SPLAT, LEVEL, MESSAGE } = require("triple-beam"); + +const { customLevels, LOG_LEVEL } = require("../../config/logging"); + +const { buildTransport } = require("./streams"); + +const { sessionTransport, sqliteTransport } = require("./config.js"); +const config = require("../../config"); + +const formatMessage = (info) => { + const { timestamp, level, message } = info; + const splat = info[SPLAT] || []; + const settings = config.logging.prettyPrint; + + // util.formatWithOptions applies splat arguments using config values + const formattedMessage = util.formatWithOptions( + { + colors: settings.colors, + depth: settings.depth, + breakLength: settings.breakLength, + compact: settings.compact, + }, + message, + ...splat, + ); + + // Isolate Error for stack trace + const error = + splat.find((arg) => arg instanceof Error) || + (message instanceof Error ? message : null); + + const stack = error ? `\n${error.stack}` : ""; + + return `[${timestamp}] [${level}] ${formattedMessage}${stack}`; +}; + +const winstonLogger = createLogger({ + levels: customLevels.levels, + format: format.combine( + format.timestamp(), + format.printf( + ({ timestamp, level, message }) => `[${timestamp}] [${level}] ${message}`, + ), + ), + transports: [ + buildTransport("info", "info"), + buildTransport("event", "event"), + buildTransport("error", "error"), + buildTransport("warn", "warn"), + buildTransport("debug", "debug"), + buildTransport("notice", "notice"), + buildTransport("security", "security"), + sessionTransport, + new transports.Console({ + level: LOG_LEVEL, + format: format.combine( + format.splat(), + format.colorize(), + format.timestamp(), + format.printf(formatMessage), + ), + transports: [new transports.Console()], + }), + sqliteTransport, + ], +}); + +module.exports = { + winstonLogger, +}; diff --git a/src/utils/sendContactMail.js b/src/utils/sendContactMail.js index a147e67..5a3eff1 100644 --- a/src/utils/sendContactMail.js +++ b/src/utils/sendContactMail.js @@ -1,10 +1,10 @@ -// src/utils/sendContactMail.js const transporter = require("./transporter"); const path = require("path"); const fs = require("fs").promises; const { validateAndSanitizeEmail } = require("../utils/emailValidator"); const { winstonLogger } = require("../utils/logging"); -const { mail } = require("../config/loader"); +const config = require("../config"); +const { mail } = config; // Fixed sanitizeInput function function sanitizeInput(input) { @@ -33,7 +33,7 @@ async function sendContactMail({ name, email, subject, message }) { const cleanName = sanitizeInput(name); - const cleanSubject = sanitizeInput(subject || DEFAULT_SUBJECT); + const cleanSubject = sanitizeInput(subject || mail.defaultSubject); const cleanMessage = sanitizeInput(message); const { @@ -59,10 +59,10 @@ message: cleanMessage, }; try { - const data = await fs.readFile(EMAIL_LOG_PATH, "utf-8"); + const data = await fs.readFile(mail.logPath, "utf-8"); const logs = JSON.parse(data); logs.push(emailLogEntry); - await fs.writeFile(EMAIL_LOG_PATH, JSON.stringify(logs, null, 2)); + await fs.writeFile(mail.logPath, JSON.stringify(logs, null, 2)); } catch (err) { winstonLogger.error("Failed to log email to file:", err); throw err; diff --git a/src/utils/sendNewsletterSubscriptionMail.js b/src/utils/sendNewsletterSubscriptionMail.js index 9305667..33e6c3f 100644 --- a/src/utils/sendNewsletterSubscriptionMail.js +++ b/src/utils/sendNewsletterSubscriptionMail.js @@ -1,7 +1,7 @@ // src/utils/sendNewsletterSubscriptionMail.js const transporter = require("./transporter"); const { winstonLogger } = require("./logging"); -const { mail } = require("../config/loader"); +const { mail } = require("../config"); const MAIL_SUBJECT = "New Newsletter Subscription"; diff --git a/src/views/layouts/page.handlebars b/src/views/layouts/page.handlebars index d049ec5..79ce133 100644 --- a/src/views/layouts/page.handlebars +++ b/src/views/layouts/page.handlebars @@ -5,17 +5,17 @@ {{> site_headers }} - + {{> page_headers}} -
+
{{#if showSidebar}} -
diff --git a/src/views/layouts/presentation.handlebars b/src/views/layouts/presentation.handlebars index 0ef0b4b..72e7841 100644 --- a/src/views/layouts/presentation.handlebars +++ b/src/views/layouts/presentation.handlebars @@ -25,8 +25,8 @@ - +