From 861f38d66648a5f507295a9808d1589d3085a86e Mon Sep 17 00:00:00 2001 From: TheFlow Date: Fri, 17 Apr 2026 14:46:49 +1200 Subject: [PATCH] feat: add /blog index route + friendly HTML 404 page Previously /blog returned JSON 404 because only /blog/:slug was wired; now app.get('/blog') serves the existing public/blog.html. notFound middleware now detects browser GETs (Accept: text/html, path not /api/*) and serves public/404.html; API clients keep JSON. Added public/404.html styled to match the site theme. Also cleaned up 11 pre-existing lint errors in src/server.js (unused rate-limit/csrf imports, brace-style on the cache-control chain) while editing the file. --- public/404.html | 43 +++++++++++++++++++++++ src/middleware/error.middleware.js | 25 ++++++++++++- src/server.js | 56 ++++++++++++++---------------- 3 files changed, 93 insertions(+), 31 deletions(-) create mode 100644 public/404.html diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000..dc6024ea --- /dev/null +++ b/public/404.html @@ -0,0 +1,43 @@ + + + + + + Page Not Found | Tractatus AI Safety Framework + + + + + + + + + + + + +
+
+

404

+

Page not found

+

+ The page you were looking for does not exist, may have moved, or may never have been published. +

+ +
+
+ + + + diff --git a/src/middleware/error.middleware.js b/src/middleware/error.middleware.js index 329216f0..929f9765 100644 --- a/src/middleware/error.middleware.js +++ b/src/middleware/error.middleware.js @@ -6,8 +6,31 @@ const logger = require('../utils/logger.util'); /** * 404 Not Found handler + * + * Browser requests (Accept: text/html, GET) get the friendly 404.html page. + * API clients (Accept: application/json, non-GET, /api/* paths) get JSON. */ function notFound(req, res, _next) { + const path = require('path'); + const isApiPath = req.originalUrl.startsWith('/api/'); + const wantsHtml = req.method === 'GET' + && !isApiPath + && req.accepts(['html', 'json']) === 'html'; + + if (wantsHtml) { + return res.status(404).sendFile( + path.resolve(__dirname, '..', '..', 'public', '404.html'), + err => { + if (err) { + res.status(404).json({ + error: 'Not Found', + message: `Route ${req.method} ${req.originalUrl} not found` + }); + } + } + ); + } + res.status(404).json({ error: 'Not Found', message: `Route ${req.method} ${req.originalUrl} not found` @@ -17,7 +40,7 @@ function notFound(req, res, _next) { /** * Global error handler */ -function errorHandler(err, req, res, next) { +function errorHandler(err, req, res, _next) { // Log error logger.error('Error:', { message: err.message, diff --git a/src/server.js b/src/server.js index f622132b..c5968a34 100644 --- a/src/server.js +++ b/src/server.js @@ -8,7 +8,6 @@ require('dotenv').config(); const express = require('express'); const helmet = require('helmet'); const cors = require('cors'); -const rateLimit = require('express-rate-limit'); const cookieParser = require('cookie-parser'); // const csrf = require('csurf'); // Disabled - deprecated package, will implement modern solution in Phase 3 @@ -20,9 +19,9 @@ const { notFound, errorHandler } = require('./middleware/error.middleware'); // Security middleware (Quick Wins) const { securityHeadersMiddleware } = require('./middleware/security-headers.middleware'); -const { publicRateLimiter, formRateLimiter, authRateLimiter } = require('./middleware/rate-limit.middleware'); +const { publicRateLimiter } = require('./middleware/rate-limit.middleware'); const { sanitizeErrorResponse, sanitizeResponseData } = require('./middleware/response-sanitization.middleware'); -const { setCsrfToken, csrfProtection, getCsrfToken } = require('./middleware/csrf-protection.middleware'); +const { setCsrfToken, getCsrfToken } = require('./middleware/csrf-protection.middleware'); // Create Express app const app = express(); @@ -84,50 +83,47 @@ app.use(publicRateLimiter); app.use((req, res, next) => { const path = req.path; - // Version manifest and service worker: No cache (always fetch fresh) if (path === '/version.json' || path === '/service-worker.js') { + // Version manifest and service worker: never cache (always fetch fresh) res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - } - // HTML files: No cache (always fetch fresh - users must see updates immediately) - else if (path.endsWith('.html') || path === '/') { + } else if (path.endsWith('.html') || path === '/') { + // HTML: never cache — users must see updates immediately res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); res.setHeader('Pragma', 'no-cache'); - } - // Admin JS/HTML files: NEVER cache (always fetch fresh) - else if (path.startsWith('/js/admin/') || path.startsWith('/admin/')) { + } else if (path.startsWith('/js/admin/') || path.startsWith('/admin/')) { + // Admin JS/HTML: never cache (always fetch fresh) res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - } - // CSS and JS files: Short cache for active development - // With versioned URLs (?v=timestamp), browsers will fetch new versions when HTML updates - else if (path.endsWith('.css') || path.endsWith('.js')) { - res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour - reasonable for active development - } - // Images and fonts: Long cache - else if (path.match(/\.(jpg|jpeg|png|gif|svg|ico|woff|woff2|ttf|eot)$/)) { - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year - } - // PWA manifest: Medium cache - else if (path === '/manifest.json') { - res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day - } - // API responses: NEVER cache (security middleware sets this too, but catch-all below would override) - else if (path.startsWith('/api/')) { + } else if (path.endsWith('.css') || path.endsWith('.js')) { + // CSS/JS: 1 hour — versioned URLs force fresh fetch when HTML updates + res.setHeader('Cache-Control', 'public, max-age=3600'); + } else if (path.match(/\.(jpg|jpeg|png|gif|svg|ico|woff|woff2|ttf|eot)$/)) { + // Images/fonts: 1 year, immutable + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } else if (path === '/manifest.json') { + // PWA manifest: 1 day + res.setHeader('Cache-Control', 'public, max-age=86400'); + } else if (path.startsWith('/api/')) { + // API: never cache (security middleware sets this too; catch-all below would otherwise override) res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - } - // Everything else (static assets without extensions): Short cache - else { - res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour + } else { + // Static assets without extension: 1 hour + res.setHeader('Cache-Control', 'public, max-age=3600'); } next(); }); +// Blog index: /blog → blog.html (before /blog/:slug so "/blog" doesn't get treated as a slug) +app.get('/blog', (req, res) => { + res.sendFile('blog.html', { root: 'public' }); +}); + // Blog post URL rewriting: /blog/:slug → /blog-post.html?slug=:slug // This provides cleaner URLs for blog posts (SEO-friendly) app.get('/blog/:slug', (req, res) => {