Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ model User {
conversations ConversationMember[]

@@index([pvpRating])
@@index([updatedAt])
refreshTokens RefreshToken[]
bans Ban[] @relation("bannedUser")
mutes Mute[] @relation("mutedUser")
Expand Down Expand Up @@ -133,6 +134,7 @@ model Mission {
contributions MissionContribution[]

@@index([clanId, status])
@@index([clanId, type, status])
}

model MissionContribution {
Expand Down Expand Up @@ -333,6 +335,7 @@ model Purchase {

@@index([userId, createdAt])
@@index([status])
@@index([stripePaymentId])
}

model MonsterTemplate {
Expand Down
4 changes: 2 additions & 2 deletions server/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
38 changes: 30 additions & 8 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 0 additions & 7 deletions server/lib/prisma.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
26 changes: 24 additions & 2 deletions server/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 19 additions & 19 deletions server/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 }
Expand All @@ -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' });
}
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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()) {
Expand All @@ -580,19 +578,16 @@ 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 },
});

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' });
}
Expand All @@ -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
Expand All @@ -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 },
Expand Down
6 changes: 4 additions & 2 deletions server/routes/clans.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading
Loading