diff --git a/src/controllers/blogControllers.js b/src/controllers/blogControllers.js index 9415501..2a3817a 100644 --- a/src/controllers/blogControllers.js +++ b/src/controllers/blogControllers.js @@ -2,6 +2,9 @@ const { marked } = require("marked"); const fs = require("fs").promises; const path = require("path"); +const fsSync = require("fs"); +const crypto = require("crypto"); + const matter = require("gray-matter"); const { getAllPosts } = require("../utils/postFileUtils"); @@ -34,7 +37,28 @@ ); try { + const stat = fsSync.statSync(mdPath); const fileContent = await fs.readFile(mdPath, "utf8"); + + // Generate ETag from content hash + const hash = crypto.createHash("sha256").update(fileContent).digest("hex"); + const lastModified = stat.mtime.toUTCString(); + + res.setHeader("ETag", hash); + res.setHeader("Last-Modified", lastModified); + + // Check conditional request headers + if ( + req.headers["if-none-match"] === hash || + req.headers["if-modified-since"] === lastModified + ) { + res.statusCode = 304; + return res.end(); + } + if (req.checkCacheHeaders({ etag: hash, lastModified })) { + return; + } + const { data: frontmatter, content } = matter(fileContent); if ( !frontmatter.published && @@ -71,6 +95,18 @@ // Sort posts descending by date publishedPosts.sort((a, b) => new Date(b.date) - new Date(a.date)); + const etagInput = publishedPosts.map((p) => p.id).join(","); + const etag = `"${hash(etagInput)}"`; + + const lastModified = + publishedPosts.length > 0 + ? new Date( + Math.max(...publishedPosts.map((p) => new Date(p.date).getTime())) + ).toUTCString() + : new Date().toUTCString(); + + if (req.checkCacheHeaders({ etag, lastModified })) return; + // Prepare context compatible with the blog-index.hbs layout // Add `templateContent` as excerpt or limited content if needed here // For now, use a simple excerpt from markdown or placeholder diff --git a/src/controllers/docsControllers.js b/src/controllers/docsControllers.js index 8789d30..19c7ac3 100644 --- a/src/controllers/docsControllers.js +++ b/src/controllers/docsControllers.js @@ -1,4 +1,6 @@ const fs = require("fs/promises"); +const path = require("path"); +const { createHash } = require("crypto"); const { qualifyLink } = require("../utils/qualifyLinks"); const { baseUrl } = require("../utils/baseUrl"); const HttpError = require("../utils/HttpError"); @@ -10,9 +12,49 @@ docsDir, } = require("../services/docsService"); +function computeHash(input) { + return createHash("sha1").update(input).digest("hex"); +} + +async function checkDocCache(req, fileNamesOrPath, namespace = "") { + const files = Array.isArray(fileNamesOrPath) + ? fileNamesOrPath + : [fileNamesOrPath]; + + const statPaths = await Promise.all( + files.map(async (f) => { + const filePath = path.join(docsDir, f + ".yaml"); + try { + const stat = await fs.stat(filePath); + return stat; + } catch (err) { + throw err; + } + }) + ); + + const lastModified = new Date( + Math.max(...statPaths.map((s) => s.mtimeMs)) + ).toUTCString(); + + const hashBase = Array.isArray(fileNamesOrPath) + ? fileNamesOrPath.join(",") + : fileNamesOrPath; + + const etag = `"${computeHash(namespace + hashBase)}"`; + + const result = req.checkCacheHeaders({ etag, lastModified }); + + return result; +} + exports.renderDocsIndex = async (req, res, next) => { try { const yamlFiles = await getYamlFileNames(); + + // Read from cache + if (await checkDocCache(req, yamlFiles)) return; + const context = await docsContext(req.isAuthenticated, { layout: "docs", docPath: "/docs", @@ -24,8 +66,7 @@ docsPaths: yamlFiles.map((name) => `${req.baseUrl || ""}/${name}`), }); } catch (err) { - req.log.error(err.stack); - next(new HttpError("Failed to read docs directory", 500)); + next(new HttpError("Failed to read docs directory", 500, err.stack)); } }; @@ -34,6 +75,9 @@ const summaries = []; const yamlFiles = await getYamlFileNames(); + // Read from cache + if (await checkDocCache(req, yamlFiles, "summary")) return; + for (const file of yamlFiles) { const doc = await loadDocFile(file); if (doc?.crossCuttingSummary) { @@ -58,6 +102,10 @@ exports.renderDocsByType = async (req, res, next) => { const { moduleType: docPath } = req.params; + + // Read from cache + if (await checkDocCache(req, docPath)) return; + const doc = await loadDocFile(docPath); const context = await docsContext(req.isAuthenticated, { @@ -82,8 +130,13 @@ exports.renderDocsModule = async (req, res, next) => { const { moduleType: docPath, module } = req.params; + + // Read from cache + if (await checkDocCache(req, docPath)) return; + const doc = await loadDocFile(docPath); if (!doc) return next(new HttpError("Documentation not found", 404)); + const moduleDoc = doc.modules[module]; if (!moduleDoc) return next(new HttpError("Module documentation not found", 404)); diff --git a/src/controllers/errorPageController.js b/src/controllers/errorPageController.js index 19f49c0..7ae5a9c 100644 --- a/src/controllers/errorPageController.js +++ b/src/controllers/errorPageController.js @@ -2,7 +2,7 @@ const { getErrorContext } = require("../utils/errorContext"); module.exports = async (req, res) => { - const code = parseInt(req.query.code, 10) || 500; + const code = req.params.code || parseInt(req.query.code, 10) || 500; const errorContext = getErrorContext(code); const context = { diff --git a/src/middleware/cacheUtils.js b/src/middleware/cacheUtils.js new file mode 100644 index 0000000..1f12339 --- /dev/null +++ b/src/middleware/cacheUtils.js @@ -0,0 +1,28 @@ +// middleware/cacheMiddleware.js +function cacheMiddleware(req, res, next) { + req.checkCacheHeaders = ({ etag, lastModified }) => { + const ifNoneMatch = req.headers["if-none-match"]; + const ifModifiedSince = req.headers["if-modified-since"]; + + if (ifNoneMatch === etag) { + res.status(304).end(); + return true; + } + + if ( + ifModifiedSince && + new Date(ifModifiedSince) >= new Date(lastModified) + ) { + res.status(304).end(); + return true; + } + + res.setHeader("ETag", etag); + res.setHeader("Last-Modified", lastModified); + return false; + }; + + next(); +} + +module.exports = cacheMiddleware; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 4488c90..b72ac93 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -47,9 +47,7 @@ const errorContext = getErrorContext(code || statusCode); if (!isDev && !req?.isAuthenticated) { - res.customRedirect( - `${ERROR_REDIRECT_PATH}?code=${errorContext.statusCode}` - ); + res.customRedirect(`${ERROR_REDIRECT_PATH}/${errorContext.statusCode}`); return; } diff --git a/src/middleware/index.js b/src/middleware/index.js index be23b23..4640ffe 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -20,7 +20,8 @@ const securedRoutes = require("../routes/secured"); const adaptiveBodyParser = require("./adaptiveBodyParser"); const analytics = require("../controllers/analyticsControllers"); -const structuredLogger = require("../utils/structuredLogger"); +const httpLogger = require("../utils/structuredLogger"); +const cacheUtils = require("./cacheUtils"); function setupApp() { const app = express(); @@ -32,7 +33,7 @@ app.use(hbs); // Setup logging - app.use(structuredLogger, loggingMiddleware); + app.use(httpLogger, loggingMiddleware); app.use(authCheck); @@ -51,6 +52,7 @@ app.use(validateRequestIntegrity); app.use(formatHtml); app.use(redirectMiddleware); + app.use(cacheUtils); app.post("/track", logEvent("analytics"), analytics); app.post("/analytics", logEvent("analytics"), analytics); app.use("/admin", logEvent("admin"), securedMiddleware, securedRoutes); diff --git a/src/routes/index.js b/src/routes/index.js index 1658abc..42e0510 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -28,6 +28,8 @@ res.sendStatus(200); }); +router.get("/error/:code", errorPage); // Landing page after error is logged + router.get("/error", errorPage); // Landing page after error is logged router.use(admin); diff --git a/src/utils/structuredLogger.js b/src/utils/structuredLogger.js index e7473a7..ca3ffa8 100644 --- a/src/utils/structuredLogger.js +++ b/src/utils/structuredLogger.js @@ -7,6 +7,20 @@ return null; } +// Flatten nested objects into key-value pairs for metadata +const flatten = (obj, prefix = "") => { + if (!obj || typeof obj !== "object") return {}; + const res = {}; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === "object") { + Object.assign(res, flatten(v, key)); + } else { + res[key] = String(v); + } + } + return res; +}; module.exports = (req, res, next) => { res.on("finish", () => { const { method, url, headers, query, body, ip, connection } = req; @@ -14,21 +28,6 @@ let logLevel = determineLogLevel(statusCode); if (logLevel) { - // Flatten nested objects into key-value pairs for metadata - const flatten = (obj, prefix = "") => { - if (!obj || typeof obj !== "object") return {}; - const res = {}; - for (const [k, v] of Object.entries(obj)) { - const key = prefix ? `${prefix}.${k}` : k; - if (v !== null && typeof v === "object") { - Object.assign(res, flatten(v, key)); - } else { - res[key] = String(v); - } - } - return res; - }; - const meta = { statusCode: String(statusCode), directIp: String(connection.remoteAddress),