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
2 changes: 1 addition & 1 deletion server/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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_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
14 changes: 13 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,19 @@ app.set('trust proxy', NODE_ENV === 'production' ? 1 : false);

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

Expand Down
13 changes: 9 additions & 4 deletions server/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ function generateRefreshToken(user) {
);
}

/** SHA-256 hash a token before storing — the raw token is only sent to the client */
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}

/** Store refresh token in DB and return both tokens as JSON */
async function issueTokens(user, res, statusCode = 200) {
const accessToken = generateAccessToken(user);
Expand All @@ -47,7 +52,7 @@ async function issueTokens(user, res, statusCode = 200) {
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 Down Expand Up @@ -476,7 +481,7 @@ router.post('/refresh', async (req, res) => {

// Check token exists in DB (not revoked)
const stored = await prisma.refreshToken.findUnique({
where: { token: refreshToken }
where: { token: hashToken(refreshToken) }
});
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
47 changes: 27 additions & 20 deletions server/routes/clans.js
Original file line number Diff line number Diff line change
Expand Up @@ -563,28 +563,35 @@ 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 } });
// Check membership outside the transaction for an early 400 response.
const existing = await getMembership(req.user.userId);
if (!existing) return res.status(400).json({ error: 'You are not in a clan' });

// 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) => {
// Re-fetch membership inside the transaction for consistency.
const membership = await tx.clanMember.findUnique({ where: { userId: req.user.userId } });
if (!membership) return; // already gone — nothing to do

const clanId = membership.clanId;
const wasOwner = membership.role === 'owner';

await tx.clanMember.delete({ where: { userId: req.user.userId } });

// If the owner left, transfer ownership to the next-oldest member, or disband if empty.
if (wasOwner) {
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) {
console.error('Leave clan error:', err);
Expand Down
37 changes: 32 additions & 5 deletions server/routes/pvp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -11,7 +12,16 @@ import { pickOpponent, attackerEloChange } from '../lib/pvp-match.js';

const router = Router();

const CANDIDATE_POOL = 100; // recent players considered as opponents per fight
const fightLimiter = rateLimit({
windowMs: 10 * 1000, // 10 seconds
max: 2, // max 2 fights per 10 seconds
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many fights, please wait a moment' },
});

const CANDIDATE_POOL = 20; // players considered as opponents per fight
const RATING_RANGE = 300; // preferred pvpRating proximity for matchmaking

const USER_SELECT = {
id: true, username: true, profilePicture: true, pvpRating: true,
Expand Down Expand Up @@ -85,7 +95,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', requireAuth, fightLimiter, async (req, res) => {
try {
const me = await prisma.user.findUnique({
where: { id: req.user.userId },
Expand All @@ -106,12 +116,29 @@ router.post('/fight', requireAuth, async (req, res) => {
if (!target || !target.gameState) return res.status(400).json({ error: 'That player has no battle data yet' });
opponent = fighterFromUser(target);
} else {
const others = await prisma.user.findMany({
where: { id: { not: me.id }, gameState: { isNot: null } },
// Prefer opponents whose Elo is close to the attacker's; fall back
// to a wider search when the narrow band returns too few candidates.
const myRating = me.pvpRating ?? 1000;
const ratingFilter = {
id: { not: me.id },
gameState: { isNot: null },
pvpRating: { gte: myRating - RATING_RANGE, lte: myRating + RATING_RANGE },
};
let others = await prisma.user.findMany({
where: ratingFilter,
select: USER_SELECT,
orderBy: { updatedAt: 'desc' },
orderBy: { pvpRating: 'asc' },
take: CANDIDATE_POOL,
});
// Broaden if too few nearby — drop the rating constraint entirely.
if (others.length < 3) {
others = await prisma.user.findMany({
where: { id: { not: me.id }, gameState: { isNot: null } },
select: USER_SELECT,
orderBy: { pvpRating: 'asc' },
take: CANDIDATE_POOL,
});
}
opponent = pickOpponent(others.map(fighterFromUser), attacker.power) || mirrorBot(attacker);
}

Expand Down
15 changes: 15 additions & 0 deletions src/screens/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export function h(tag, props = {}, ...children) {
return el;
}

/**
* Escape user-generated strings for safe insertion into HTML.
* Use this when content must go through innerHTML; prefer textContent (or the
* h() helper's `text` prop) whenever possible — it escapes automatically.
*/
export function escapeHtml(str) {
if (typeof str !== 'string') return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

export function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
return node;
Expand Down
Loading