159 lines
5.7 KiB
JavaScript
159 lines
5.7 KiB
JavaScript
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 };
|