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
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ model Purchase {

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

model MonsterTemplate {
Expand Down
32 changes: 30 additions & 2 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,17 @@ app.set('trust proxy', NODE_ENV === 'production' ? 1 : false);

// Security headers
app.use(helmet({
contentSecurityPolicy: false,
contentSecurityPolicy: NODE_ENV === 'production' ? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'", "wss:", "ws:", "https://accounts.google.com", "https://discord.com", "https://checkout.stripe.com"],
frameSrc: ["https://accounts.google.com", "https://discord.com", "https://checkout.stripe.com"],
},
} : false,
hsts: NODE_ENV === 'production' ? { maxAge: 31536000, includeSubDomains: true } : false,
}));

Expand All @@ -62,6 +72,16 @@ const apiLimiter = rateLimit({
});
app.use('/api/', apiLimiter);

// Stricter rate limit for game state saves (30/min per IP)
const stateSaveLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many save requests, please try again later' },
});
app.use('/api/game/state', stateSaveLimiter);

// Stripe webhook needs raw body for signature verification — must be registered before express.json()
app.use('/api/payment/webhook', express.raw({ type: 'application/json' }));

Expand Down Expand Up @@ -89,7 +109,15 @@ const io = setupSocket(server);

// Serve static frontend in production
const distPath = path.join(__dirname, '..', 'dist');
app.use(express.static(distPath));
// Hashed assets (Vite output) are immutable — cache aggressively
app.use('/assets', express.static(path.join(distPath, 'assets'), {
maxAge: '1y',
immutable: true,
}));
app.use(express.static(distPath, {
maxAge: '0',
etag: true,
}));

// Admin dashboard — serve admin.html for /admin route
app.get('/admin', (req, res) => {
Expand Down
4 changes: 2 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 Down Expand Up @@ -106,7 +106,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
39 changes: 24 additions & 15 deletions server/routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,18 @@ router.post('/users/:id/gold', requireRole('admin'), async (req, res) => {
}

try {
const state = await prisma.gameState.findUnique({ where: { userId } });
if (!state) return res.status(404).json({ error: 'Game state not found' });

const newGold = Math.max(0, state.gold + Math.floor(amount));
await prisma.gameState.update({ where: { userId }, data: { gold: newGold } });
const newGold = await prisma.$transaction(async (tx) => {
const state = await tx.gameState.findUnique({ where: { userId } });
if (!state) throw Object.assign(new Error('Game state not found'), { status: 404 });
const gold = Math.max(0, state.gold + Math.floor(amount));
await tx.gameState.update({ where: { userId }, data: { gold } });
return gold;
});
await logAudit(req.user.userId, 'add_gold', userId, { amount, newGold });

res.json({ gold: newGold });
} catch (err) {
if (err.status) return res.status(err.status).json({ error: err.message });
console.error('Add gold error:', err);
res.status(500).json({ error: 'Internal server error' });
}
Expand All @@ -355,15 +358,18 @@ router.post('/users/:id/essence', requireRole('admin'), async (req, res) => {
}

try {
const state = await prisma.gameState.findUnique({ where: { userId } });
if (!state) return res.status(404).json({ error: 'Game state not found' });

const newEssence = Math.max(0, state.essence + Math.floor(amount));
await prisma.gameState.update({ where: { userId }, data: { essence: newEssence } });
const newEssence = await prisma.$transaction(async (tx) => {
const state = await tx.gameState.findUnique({ where: { userId } });
if (!state) throw Object.assign(new Error('Game state not found'), { status: 404 });
const essence = Math.max(0, state.essence + Math.floor(amount));
await tx.gameState.update({ where: { userId }, data: { essence } });
return essence;
});
await logAudit(req.user.userId, 'add_essence', userId, { amount, newEssence });

res.json({ essence: newEssence });
} catch (err) {
if (err.status) return res.status(err.status).json({ error: err.message });
console.error('Add essence error:', err);
res.status(500).json({ error: 'Internal server error' });
}
Expand All @@ -379,15 +385,18 @@ router.post('/users/:id/diamonds', requireRole('admin'), async (req, res) => {
}

try {
const state = await prisma.gameState.findUnique({ where: { userId } });
if (!state) return res.status(404).json({ error: 'Game state not found' });

const newDiamonds = Math.max(0, state.diamonds + Math.floor(amount));
await prisma.gameState.update({ where: { userId }, data: { diamonds: newDiamonds } });
const newDiamonds = await prisma.$transaction(async (tx) => {
const state = await tx.gameState.findUnique({ where: { userId } });
if (!state) throw Object.assign(new Error('Game state not found'), { status: 404 });
const diamonds = Math.max(0, state.diamonds + Math.floor(amount));
await tx.gameState.update({ where: { userId }, data: { diamonds } });
return diamonds;
});
await logAudit(req.user.userId, 'add_diamonds', userId, { amount, newDiamonds });

res.json({ diamonds: newDiamonds });
} catch (err) {
if (err.status) return res.status(err.status).json({ error: err.message });
console.error('Add diamonds error:', err);
res.status(500).json({ error: 'Internal server error' });
}
Expand Down
19 changes: 12 additions & 7 deletions server/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,19 @@ function generateRefreshToken(user) {
);
}

/** Store refresh token in DB and return both tokens as JSON */
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}

/** Store refresh token hash in DB and return both tokens as JSON */
async function issueTokens(user, res, statusCode = 200) {
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);

const decoded = jwt.decode(refreshToken);
await prisma.refreshToken.create({
data: {
token: refreshToken,
token: hashToken(refreshToken),
userId: user.id,
expiresAt: new Date(decoded.exp * 1000),
}
Expand All @@ -73,7 +77,7 @@ async function createDefaultGameState(userId) {
data: {
userId,
equipment: {},
gold: 0,
gold: 100,
forgeLevel: 1,
combat: { currentWave: 1, currentSubWave: 1, highestWave: 1, highestSubWave: 1 },
}
Expand Down Expand Up @@ -474,9 +478,10 @@ router.post('/refresh', async (req, res) => {
try {
const payload = jwt.verify(refreshToken, JWT_REFRESH_SECRET);

// Check token exists in DB (not revoked)
// Lookup by hash — the raw token is never stored
const tokenHash = hashToken(refreshToken);
const stored = await prisma.refreshToken.findUnique({
where: { token: refreshToken }
where: { token: tokenHash }
});
if (!stored) {
return res.status(401).json({ error: 'Token revoked' });
Expand All @@ -496,7 +501,7 @@ router.post('/refresh', async (req, res) => {
prisma.refreshToken.delete({ where: { id: stored.id } }),
prisma.refreshToken.create({
data: {
token: newRefreshToken,
token: hashToken(newRefreshToken),
userId: user.id,
expiresAt: new Date(decoded.exp * 1000),
}
Expand All @@ -520,7 +525,7 @@ router.post('/logout', requireAuth, async (req, res) => {
try {
if (refreshToken) {
await prisma.refreshToken.deleteMany({
where: { token: refreshToken, userId: req.user.userId }
where: { token: hashToken(refreshToken), userId: req.user.userId }
});
} else {
// Delete all refresh tokens for this user
Expand Down
72 changes: 41 additions & 31 deletions server/routes/clans.js
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ router.get('/:id', requireAuth, async (req, res) => {
});

// POST /api/clans — create a clan (creator becomes owner)
const CLAN_CREATE_COST = 500;
router.post('/', requireAuth, async (req, res) => {
const fieldError = validClanFields(req.body || {});
if (fieldError) return res.status(400).json({ error: fieldError });
Expand All @@ -508,22 +509,31 @@ router.post('/', requireAuth, async (req, res) => {
const description = (req.body.description || '').toString();

try {
const existing = await getMembership(req.user.userId);
if (existing) return res.status(409).json({ error: 'You are already in a clan' });

const clan = await prisma.clan.create({
data: {
name, tag, emblem, description,
ownerId: req.user.userId,
members: { create: { userId: req.user.userId, role: 'owner' } },
},
include: FULL_CLAN_INCLUDE,
const clan = await prisma.$transaction(async (tx) => {
const existing = await tx.clanMember.findUnique({ where: { userId: req.user.userId } });
if (existing) throw Object.assign(new Error('You are already in a clan'), { status: 409 });

const gs = await tx.gameState.findUnique({ where: { userId: req.user.userId }, select: { gold: true } });
if (!gs || gs.gold < CLAN_CREATE_COST) {
throw Object.assign(new Error(`You need ${CLAN_CREATE_COST} gold to found a clan`), { status: 400 });
}
await tx.gameState.update({ where: { userId: req.user.userId }, data: { gold: { decrement: CLAN_CREATE_COST } } });

return tx.clan.create({
data: {
name, tag, emblem, description,
ownerId: req.user.userId,
members: { create: { userId: req.user.userId, role: 'owner' } },
},
include: FULL_CLAN_INCLUDE,
});
});
res.status(201).json(serializeClan(clan, { withMembers: true }));
} catch (err) {
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A clan with that name or tag already exists' });
}
if (err.status) return res.status(err.status).json({ error: err.message });
console.error('Create clan error:', err);
res.status(500).json({ error: 'Failed to create clan' });
}
Expand Down Expand Up @@ -563,30 +573,30 @@ router.post('/:id/join', requireAuth, async (req, res) => {
// POST /api/clans/leave — leave the current clan (owner transfers or disbands)
router.post('/leave', requireAuth, async (req, res) => {
try {
const membership = await getMembership(req.user.userId);
if (!membership) return res.status(400).json({ error: 'You are not in a clan' });

const clanId = membership.clanId;
await prisma.clanMember.delete({ where: { userId: req.user.userId } });

// If the owner left, transfer ownership to the next-oldest member, or disband if empty.
const clan = await prisma.clan.findUnique({ where: { id: clanId } });
if (clan && clan.ownerId === req.user.userId) {
const next = await prisma.clanMember.findFirst({
where: { clanId },
orderBy: [{ role: 'asc' }, { joinedAt: 'asc' }],
});
if (next) {
await prisma.$transaction([
prisma.clan.update({ where: { id: clanId }, data: { ownerId: next.userId } }),
prisma.clanMember.update({ where: { id: next.id }, data: { role: 'owner' } }),
]);
} else {
await prisma.clan.delete({ where: { id: clanId } });
await prisma.$transaction(async (tx) => {
const membership = await tx.clanMember.findUnique({ where: { userId: req.user.userId } });
if (!membership) throw Object.assign(new Error('You are not in a clan'), { status: 400 });

const clanId = membership.clanId;
await tx.clanMember.delete({ where: { userId: req.user.userId } });

const clan = await tx.clan.findUnique({ where: { id: clanId } });
if (clan && clan.ownerId === req.user.userId) {
const next = await tx.clanMember.findFirst({
where: { clanId },
orderBy: [{ role: 'asc' }, { joinedAt: 'asc' }],
});
if (next) {
await tx.clan.update({ where: { id: clanId }, data: { ownerId: next.userId } });
await tx.clanMember.update({ where: { id: next.id }, data: { role: 'owner' } });
} else {
await tx.clan.delete({ where: { id: clanId } });
}
}
}
});
res.json({ ok: true });
} catch (err) {
if (err.status) return res.status(err.status).json({ error: err.message });
console.error('Leave clan error:', err);
res.status(500).json({ error: 'Failed to leave clan' });
}
Expand Down
26 changes: 15 additions & 11 deletions server/routes/payment.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import prisma from '../lib/prisma.js';

const router = Router();

// Initialize Stripe (lazy — only when keys are configured)
// Initialize Stripe (lazy singleton — only when keys are configured)
let _stripe = null;
function getStripe() {
if (!STRIPE_SECRET_KEY) {
throw new Error('Stripe is not configured');
}
return new Stripe(STRIPE_SECRET_KEY);
if (!_stripe) _stripe = new Stripe(STRIPE_SECRET_KEY);
return _stripe;
}

/**
Expand Down Expand Up @@ -250,24 +252,26 @@ router.post('/webhook', async (req, res) => {
});

if (purchase && purchase.status === 'completed') {
// Atomically claim the refund so it can't double-reverse.
const claim = await prisma.purchase.updateMany({
where: { id: purchase.id, status: 'completed' },
data: { status: 'refunded' },
});
const clawedBack = await prisma.$transaction(async (tx) => {
const claim = await tx.purchase.updateMany({
where: { id: purchase.id, status: 'completed' },
data: { status: 'refunded' },
});
if (claim.count === 0) return false;

if (claim.count > 0) {
// Clawback the gold, clamped so the balance can't go negative.
const gs = await prisma.gameState.findUnique({
const gs = await tx.gameState.findUnique({
where: { userId: purchase.userId },
select: { gold: true },
});
const newGold = Math.max(0, (gs?.gold ?? 0) - purchase.goldGranted);
await prisma.gameState.update({
await tx.gameState.update({
where: { userId: purchase.userId },
data: { gold: newGold },
});
return true;
});

if (clawedBack) {
await logAudit(purchase.userId, 'refund_gold', purchase.userId, {
packId: purchase.packId,
gold: purchase.goldGranted,
Expand Down
14 changes: 2 additions & 12 deletions server/socket/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,12 +441,7 @@ export function registerChatHandlers(io, socket) {
role: membership.role,
} : null;

// Get requesting user's role to decide what to include
const requestingUser = await prisma.user.findUnique({
where: { id: socket.user.userId },
select: { role: true },
});
const isStaff = requestingUser && (requestingUser.role === 'admin' || requestingUser.role === 'moderator');
const isStaff = socket.user.role === 'admin' || socket.user.role === 'moderator';

const profileData = {
userId: user.id,
Expand Down Expand Up @@ -512,12 +507,7 @@ export function registerChatHandlers(io, socket) {
if (!messageId || typeof messageId !== 'number') return;

try {
// Check role
const user = await prisma.user.findUnique({
where: { id: socket.user.userId },
select: { role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'moderator')) return;
if (socket.user.role !== 'admin' && socket.user.role !== 'moderator') return;

const message = await prisma.chatMessage.findUnique({ where: { id: messageId } });
if (!message) return;
Expand Down
Loading