diff --git a/package-lock.json b/package-lock.json index ab6a82e..6d1ef62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@prisma/client": "^5.22.0", "bcryptjs": "^3.0.2", + "compression": "^1.8.1", "cors": "^2.8.6", "dotenv": "^17.2.4", "express": "^5.2.1", @@ -1458,6 +1459,60 @@ "node": ">= 16" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -2778,6 +2833,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 9b738fd..e450f8e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "@prisma/client": "^5.22.0", "bcryptjs": "^3.0.2", + "compression": "^1.8.1", "cors": "^2.8.6", "dotenv": "^17.2.4", "express": "^5.2.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9e39a7c..19a146c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { conversations ConversationMember[] @@index([pvpRating]) + @@index([updatedAt]) refreshTokens RefreshToken[] bans Ban[] @relation("bannedUser") mutes Mute[] @relation("mutedUser") @@ -133,6 +134,7 @@ model Mission { contributions MissionContribution[] @@index([clanId, status]) + @@index([clanId, type, status]) } model MissionContribution { @@ -333,6 +335,7 @@ model Purchase { @@index([userId, createdAt]) @@index([status]) + @@index([stripePaymentId]) } model MonsterTemplate { diff --git a/server/config.js b/server/config.js index cbb809a..4863ef4 100644 --- a/server/config.js +++ b/server/config.js @@ -3,8 +3,8 @@ import 'dotenv/config'; export const PORT = process.env.PORT || 3000; export const NODE_ENV = process.env.NODE_ENV || 'development'; -export const JWT_SECRET = process.env.JWT_SECRET || (NODE_ENV === 'production' ? (() => { throw new Error('JWT_SECRET env var is required in production'); })() : 'dev-jwt-secret-change-in-production'); -export const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || (NODE_ENV === 'production' ? (() => { throw new Error('JWT_REFRESH_SECRET env var is required in production'); })() : 'dev-jwt-secret-change-in-production'); +export const JWT_SECRET = process.env.JWT_SECRET || (NODE_ENV === 'production' ? (() => { throw new Error('JWT_SECRET env var is required in production'); })() : 'dev-jwt-access-secret-change-in-production'); +export const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || (NODE_ENV === 'production' ? (() => { throw new Error('JWT_REFRESH_SECRET env var is required in production'); })() : 'dev-jwt-refresh-secret-change-in-production'); export const JWT_ACCESS_EXPIRY = '15m'; export const JWT_REFRESH_EXPIRY = '30d'; diff --git a/server/index.js b/server/index.js index d9da8ac..b58608f 100644 --- a/server/index.js +++ b/server/index.js @@ -6,7 +6,9 @@ import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import { PORT, NODE_ENV, CORS_ORIGIN } from './config.js'; +import compression from 'compression'; import { setupSocket } from './socket/index.js'; +import { requireNotBanned } from './middleware/auth.js'; import authRoutes from './routes/auth.js'; import gameRoutes from './routes/game.js'; import adminRoutes from './routes/admin.js'; @@ -45,13 +47,26 @@ app.set('trust proxy', NODE_ENV === 'production' ? 1 : false); // Security headers app.use(helmet({ - contentSecurityPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://accounts.google.com", "https://js.stripe.com"], + styleSrc: ["'self'", "'unsafe-inline'", "https://accounts.google.com"], + frameSrc: ["https://accounts.google.com", "https://js.stripe.com"], + connectSrc: ["'self'", "https://discord.com", "https://accounts.google.com", "https://api.stripe.com", "wss:", "ws:"], + imgSrc: ["'self'", "data:", "blob:"], + fontSrc: ["'self'"], + }, + }, hsts: NODE_ENV === 'production' ? { maxAge: 31536000, includeSubDomains: true } : false, })); // CORS app.use(cors(CORS_ORIGIN === '*' ? { maxAge: 86400 } : { origin: CORS_ORIGIN, maxAge: 86400 })); +// Compress responses +app.use(compression()); + // Rate limiting on API routes (100 requests/min per IP) const apiLimiter = rateLimit({ windowMs: 60 * 1000, @@ -67,17 +82,17 @@ app.use('/api/payment/webhook', express.raw({ type: 'application/json' })); app.use(express.json({ limit: '16kb' })); -// API routes +// API routes — auth/admin are exempt from the ban gate; gameplay routes are not. app.use('/api/auth', authRoutes); -app.use('/api/game', gameRoutes); app.use('/api/admin', adminRoutes); -app.use('/api/payment', paymentRoutes); +app.use('/api/game', requireNotBanned, gameRoutes); +app.use('/api/payment', requireNotBanned, paymentRoutes); app.use('/api/equipment', equipmentRoutes); app.use('/api/sprites', spriteRoutes); app.use('/api/monsters', monsterRoutes); app.use('/api/players', playerRoutes); -app.use('/api/clans', clanRoutes); -app.use('/api/pvp', pvpRoutes); +app.use('/api/clans', requireNotBanned, clanRoutes); +app.use('/api/pvp', requireNotBanned, pvpRoutes); // Health check app.get('/api/health', (req, res) => { @@ -89,7 +104,11 @@ const io = setupSocket(server); // Serve static frontend in production const distPath = path.join(__dirname, '..', 'dist'); -app.use(express.static(distPath)); +app.use('/assets', express.static(path.join(distPath, 'assets'), { + maxAge: '1y', + immutable: true, +})); +app.use(express.static(distPath, { maxAge: '10m' })); // Admin dashboard — serve admin.html for /admin route app.get('/admin', (req, res) => { @@ -132,12 +151,15 @@ server.listen(PORT, async () => { } } await cleanupExpiredTokens(); - setInterval(cleanupExpiredTokens, 24 * 60 * 60 * 1000); + cleanupInterval = setInterval(cleanupExpiredTokens, 24 * 60 * 60 * 1000); }); +let cleanupInterval; + // Graceful shutdown function shutdown(signal) { console.log(`${signal} received — shutting down gracefully`); + clearInterval(cleanupInterval); io.close(); server.close(async () => { await prisma.$disconnect(); diff --git a/server/lib/prisma.js b/server/lib/prisma.js index 0f00505..2f5d2c1 100644 --- a/server/lib/prisma.js +++ b/server/lib/prisma.js @@ -4,11 +4,4 @@ const prisma = new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], }); -function gracefulShutdown(signal) { - console.log(`${signal} received — closing Prisma connection`); - prisma.$disconnect().then(() => process.exit(0)); -} -process.on('SIGINT', gracefulShutdown); -process.on('SIGTERM', gracefulShutdown); - export default prisma; diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 3369c13..e03829e 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -14,7 +14,7 @@ export function requireAuth(req, res, next) { const token = header.slice(7); try { - const payload = jwt.verify(token, JWT_SECRET); + const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); req.user = { userId: payload.userId, username: payload.username }; next(); } catch (err) { @@ -25,6 +25,28 @@ export function requireAuth(req, res, next) { } } +/** + * Express middleware — rejects requests from banned users. + * Must be used AFTER requireAuth. + */ +export function requireNotBanned(req, res, next) { + getActiveBan(req.user.userId) + .then((ban) => { + if (ban) { + return res.status(403).json({ + error: 'Your account is banned', + reason: ban.reason, + expiresAt: ban.expiresAt, + }); + } + next(); + }) + .catch((err) => { + console.error('Ban check error:', err); + next(); + }); +} + /** * Express middleware — requires user to have one of the specified roles. * Must be used AFTER requireAuth. @@ -106,7 +128,7 @@ export function socketAuth(socket, next) { } try { - const payload = jwt.verify(token, JWT_SECRET); + const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); socket.user = { userId: payload.userId, username: payload.username }; next(); } catch (err) { diff --git a/server/routes/auth.js b/server/routes/auth.js index 32b8ee7..7d0e8ad 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -88,7 +88,8 @@ function generateGuestUsername() { // ─── POST /api/auth/register ───────────────────────────────────── router.post('/register', authLimiter, [ - body('username').trim().isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters'), + body('username').trim().isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters') + .matches(/^[A-Za-z0-9_\- ]+$/).withMessage('Username may only contain letters, numbers, spaces, hyphens, and underscores'), body('email').isEmail().normalizeEmail().withMessage('Valid email required'), body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'), ], async (req, res) => { @@ -100,14 +101,6 @@ router.post('/register', authLimiter, [ const { username, email, password } = req.body; try { - const existing = await prisma.user.findFirst({ - where: { OR: [{ username }, { email }] } - }); - if (existing) { - const field = existing.username === username ? 'username' : 'email'; - return res.status(409).json({ error: `This ${field} is already taken` }); - } - const passwordHash = await bcrypt.hash(password, 12); const user = await prisma.user.create({ data: { username, email, passwordHash } @@ -116,6 +109,10 @@ router.post('/register', authLimiter, [ await createDefaultGameState(user.id); await issueTokens(user, res, 201); } catch (err) { + if (err.code === 'P2002') { + const field = err.meta?.target?.includes('email') ? 'email' : 'username'; + return res.status(409).json({ error: `This ${field} is already taken` }); + } console.error('Register error:', err); res.status(500).json({ error: 'Internal server error' }); } @@ -472,7 +469,7 @@ router.post('/refresh', async (req, res) => { } try { - const payload = jwt.verify(refreshToken, JWT_REFRESH_SECRET); + const payload = jwt.verify(refreshToken, JWT_REFRESH_SECRET, { algorithms: ['HS256'] }); // Check token exists in DB (not revoked) const stored = await prisma.refreshToken.findUnique({ @@ -570,7 +567,8 @@ router.get('/me', requireAuth, async (req, res) => { // ─── POST /api/auth/change-username ───────────────────────────── router.post('/change-username', requireAuth, [ - body('username').trim().isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters'), + body('username').trim().isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters') + .matches(/^[A-Za-z0-9_\- ]+$/).withMessage('Username may only contain letters, numbers, spaces, hyphens, and underscores'), ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { @@ -580,12 +578,6 @@ router.post('/change-username', requireAuth, [ const { username } = req.body; try { - // Check if username is taken - const existing = await prisma.user.findUnique({ where: { username } }); - if (existing && existing.id !== req.user.userId) { - return res.status(409).json({ error: 'Username already taken' }); - } - await prisma.user.update({ where: { id: req.user.userId }, data: { username }, @@ -593,6 +585,9 @@ router.post('/change-username', requireAuth, [ res.json({ message: 'Username changed', username }); } catch (err) { + if (err.code === 'P2002') { + return res.status(409).json({ error: 'Username already taken' }); + } console.error('Change username error:', err); res.status(500).json({ error: 'Internal server error' }); } @@ -606,11 +601,16 @@ router.put('/settings', requireAuth, async (req, res) => { return res.status(400).json({ error: 'Settings must be an object' }); } - // Validate known keys + // Validate and strip to known keys only const VALID_THEMES = ['dark', 'light']; if (settings.theme !== undefined && !VALID_THEMES.includes(settings.theme)) { return res.status(400).json({ error: 'Invalid theme value' }); } + const ALLOWED_KEYS = ['theme', 'language', 'sfx', 'music']; + const sanitized = {}; + for (const key of ALLOWED_KEYS) { + if (key in settings) sanitized[key] = settings[key]; + } try { // Use a transaction to atomically read-merge-write settings @@ -621,7 +621,7 @@ router.put('/settings', requireAuth, async (req, res) => { }); const current = (user?.settings && typeof user.settings === 'object') ? user.settings : {}; - const merged = { ...current, ...settings }; + const merged = { ...current, ...sanitized }; return tx.user.update({ where: { id: req.user.userId }, diff --git a/server/routes/clans.js b/server/routes/clans.js index aebfb2f..f45c162 100644 --- a/server/routes/clans.js +++ b/server/routes/clans.js @@ -626,9 +626,11 @@ router.post('/contribute', requireAuth, async (req, res) => { /** Load the actor's membership and a target member in the same clan. */ async function loadActorAndTarget(actorUserId, targetUserId) { - const actor = await prisma.clanMember.findUnique({ where: { userId: actorUserId } }); + const [actor, target] = await Promise.all([ + prisma.clanMember.findUnique({ where: { userId: actorUserId } }), + prisma.clanMember.findUnique({ where: { userId: targetUserId } }), + ]); if (!actor) return { error: { status: 400, message: 'You are not in a clan' } }; - const target = await prisma.clanMember.findUnique({ where: { userId: targetUserId } }); if (!target || target.clanId !== actor.clanId) return { error: { status: 404, message: 'Member not found in your clan' } }; if (target.userId === actor.userId) return { error: { status: 400, message: "You can't do that to yourself" } }; return { actor, target }; diff --git a/server/routes/pvp.js b/server/routes/pvp.js index fcfc0c1..6746bbc 100644 --- a/server/routes/pvp.js +++ b/server/routes/pvp.js @@ -2,6 +2,7 @@ // server-side with the shared deterministic combat engine (anti-cheat) and // replayed identically on the client. No live opponent, no real-time timers. import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { requireAuth } from '../middleware/auth.js'; import prisma from '../lib/prisma.js'; import { computeStatsFromEquipment, playerPowerScore } from '../../shared/stats.js'; @@ -11,6 +12,14 @@ import { pickOpponent, attackerEloChange } from '../lib/pvp-match.js'; const router = Router(); +const pvpFightLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many fights, slow down' }, +}); + const CANDIDATE_POOL = 100; // recent players considered as opponents per fight const USER_SELECT = { @@ -85,7 +94,7 @@ export function mirrorBot(attacker) { } // POST /api/pvp/fight — resolve one async fight and (for real opponents) apply Elo. -router.post('/fight', requireAuth, async (req, res) => { +router.post('/fight', pvpFightLimiter, requireAuth, async (req, res) => { try { const me = await prisma.user.findUnique({ where: { id: req.user.userId }, @@ -129,13 +138,24 @@ router.post('/fight', requireAuth, async (req, res) => { if (!friendly && !opponent.isBot) { ratingChange = attackerEloChange(attacker.rating, opponent.rating, win, attacker.power, opponent.power); newRating = Math.max(0, attacker.rating + ratingChange); - await prisma.user.update({ - where: { id: me.id }, - data: { - pvpRating: newRating, - ...(win ? { pvpWins: { increment: 1 } } : { pvpLosses: { increment: 1 } }), - }, - }); + const defenderChange = -ratingChange; + const defenderNewRating = Math.max(0, opponent.rating + defenderChange); + await Promise.all([ + prisma.user.update({ + where: { id: me.id }, + data: { + pvpRating: newRating, + ...(win ? { pvpWins: { increment: 1 } } : { pvpLosses: { increment: 1 } }), + }, + }), + prisma.user.update({ + where: { id: opponent.id }, + data: { + pvpRating: defenderNewRating, + ...(win ? { pvpLosses: { increment: 1 } } : { pvpWins: { increment: 1 } }), + }, + }), + ]); } res.json({ diff --git a/src/screens/home.js b/src/screens/home.js index 384dfaf..27deba0 100644 --- a/src/screens/home.js +++ b/src/screens/home.js @@ -178,6 +178,10 @@ function onFightResolved({ win }) { setArenaRank(enc.rank + 1); const xp = grantPlayerXp(arenaXp(enc.rank)); if (xp) dungeon.floater('player', `+${fmt(xp)} XP`, 'xp'); + const enemyCount = enc.enemies?.length || 1; + for (let i = 0; i < enemyCount; i++) { + gameEvents.emit(EVENTS.COMBAT_MONSTER_DEFEATED); + } } else { // On a loss, fall back one sub-stage so the player isn't stuck refighting // a deterministic fight they keep losing — but never drop a chapter: the