const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const path = require('path'); const { createSessionMiddleware, requireAuth } = require('./middleware/session'); const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth'); const { routes: characterRoutes } = require('./routes/character'); const { routes: huntRoutes } = require('./routes/hunt'); const { routes: zonesRoutes } = require('./routes/zones'); const { routes: combatLogRoutes } = require('./routes/combatLog'); const { routes: inventoryRoutes } = require('./routes/inventory'); const { routes: shopRoutes } = require('./routes/shop'); const { routes: expeditionRoutes } = require('./routes/expeditions'); const { routes: leaderboardRoutes } = require('./routes/leaderboard'); const { routes: heroProfileRoutes } = require('./routes/heroProfile'); const { AppError } = require('./utils/errors'); // General API rate limiter: 120 req/min per IP (SECURITY.md). const apiLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: process.env.NODE_ENV === 'test' ? 1000 : 120, message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many requests. Try again later.' } }, }); function createApp() { const app = express(); // Security headers app.use(helmet()); // Trust proxy (for rate limiting behind reverse proxy) app.set('trust proxy', 1); // JSON body parser with small limit app.use(express.json({ limit: '10kb' })); // Session middleware app.use(createSessionMiddleware()); // Static files app.use(express.static('public')); // Health endpoint (no auth) app.get('/api/health', (_req, res) => { res.json({ ok: true }); }); // Auth routes (public) app.post('/api/auth/register', registerLimiter, authRoutes.postRegister); app.post('/api/auth/login', loginLimiter, authRoutes.postLogin); // Auth routes (require session) app.post('/api/auth/logout', requireAuth, authRoutes.postLogout); app.get('/api/auth/me', requireAuth, authRoutes.getMe); // General API rate limiter (applies to all non-auth routes below) app.use('/api', apiLimiter); // Character routes (require session) app.post('/api/character', requireAuth, characterRoutes.postCharacter); app.get('/api/character', requireAuth, characterRoutes.getCharacter); // Hunt routes (require session) app.post('/api/hunt', requireAuth, huntRoutes.postHunt); // Zone routes (require session) app.get('/api/zones', requireAuth, zonesRoutes.getZones); // Combat log routes (require session) app.get('/api/combat-log', requireAuth, combatLogRoutes.getCombatLog); // Inventory routes (require session) app.get('/api/inventory', requireAuth, inventoryRoutes.getInventory); app.post('/api/inventory/equip', requireAuth, inventoryRoutes.postEquip); app.post('/api/inventory/unequip', requireAuth, inventoryRoutes.postUnequip); app.post('/api/inventory/use', requireAuth, inventoryRoutes.postUse); // Shop routes (require session) app.get('/api/shop', requireAuth, shopRoutes.getShop); app.post('/api/shop/buy', requireAuth, shopRoutes.postBuy); app.post('/api/shop/sell', requireAuth, shopRoutes.postSell); // Expedition routes (require session) app.get('/api/expeditions/defs', requireAuth, expeditionRoutes.getDefs); app.post('/api/expeditions/start', requireAuth, expeditionRoutes.postStart); app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive); app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim); // Leaderboard route (require session) app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard); // Hero profile route (public, no session required) app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile); // Catch-all: serve 404 page for unknown paths (HTML requests only). // API paths still return JSON errors via the central handler. // Placed before static files so it intercepts non-file paths. const PUBLIC_DIR = path.resolve(__dirname, '../public'); app.use((req, res, next) => { // If a route already set headers, let it through (e.g., static file not found) if (res.headersSent) return next(); if (req.accepts('html')) { return res.status(404).sendFile(path.join(PUBLIC_DIR, '404.html')); } return res.status(404).json({ ok: false, error: { code: 'NOT_FOUND', message: 'Resource not found.' }, }); }); // Static files (after catch-all for API paths, but static will still serve HTML pages) app.use(express.static('public')); app.use((err, _req, res, _next) => { // Log stack traces server-side only const fs = require('fs'); fs.appendFileSync('/tmp/emberfall-errors.log', `ERROR: ${err.constructor.name} | ${err.message} | stack:${err.stack ? 'yes' : 'no'} | keys:${JSON.stringify(Object.keys(err))}\n${err.stack || ''}\n`); if (err.stack) { console.error(err.stack); } if (err instanceof AppError) { return res.status(err.status).json({ ok: false, error: { code: err.code, message: err.message, }, }); } // Check for Zod-like validation errors if (err.name === 'ZodError' || (err.errors && Array.isArray(err.errors))) { return res.status(400).json({ ok: false, error: { code: 'VALIDATION_ERROR', message: 'Request validation failed.', }, }); } // Generic error if (_req.accepts('html')) { return res.status(500).sendFile(path.join(PUBLIC_DIR, '500.html')); } res.status(500).json({ ok: false, error: { code: 'INTERNAL_ERROR', message: 'Something went wrong.', }, }); }); return app; } module.exports = { createApp };