Skip to content

Commit fddb478

Browse files
deyvydclaude
andcommitted
fix: blank screen on excalibur/lady phases, XSS via player name, excalibur phase checks
- Room.tsx: render GameView on 'excalibur-usage' and 'lady-of-the-lake' phases (game froze on blank screen) - GameView: replace dangerouslySetInnerHTML with <Trans> (player names are untrusted; i18n has escapeValue:false) - server: require phase 'excalibur-usage' in use/skip-excalibur (holder could resolve mission with partial votes); forbid self-target; reset holder on team rejection - session: key sessionToken by room code (joining 2nd room broke reconnection to 1st); save token on QR/link join (Room.tsx never stored it) - ids via crypto.randomUUID instead of Math.random Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 698b512 commit fddb478

6 files changed

Lines changed: 49 additions & 23 deletions

File tree

.claude/launch.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"name": "avalon-dev",
66
"runtimeExecutable": "npm",
77
"runtimeArgs": ["run", "dev"],
8-
"port": 3000
8+
"port": 3000,
9+
"autoPort": true
910
}
1011
]
1112
}

server.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,11 @@ io.on("connection", (socket) => {
583583
socket.on("use-excalibur", ({ roomCode, targetPlayerId }) => {
584584
const room = rooms.get(roomCode);
585585
const { playerId } = socketToPlayer.get(socket.id) || {};
586-
if (!room || playerId !== room.excaliburHolder || room.excaliburUsed) return;
586+
// Checagem de fase: sem ela o holder podia resolver a missão com votos parciais
587+
if (!room || room.phase !== 'excalibur-usage') return;
588+
if (playerId !== room.excaliburHolder || room.excaliburUsed) return;
589+
// Regra: Excalibur não inverte o próprio voto
590+
if (targetPlayerId === playerId) return;
587591

588592
const originalVote = room.missionVotes[targetPlayerId];
589593
if (!originalVote) return;
@@ -602,7 +606,8 @@ io.on("connection", (socket) => {
602606
socket.on("skip-excalibur", ({ roomCode }) => {
603607
const room = rooms.get(roomCode);
604608
const { playerId } = socketToPlayer.get(socket.id) || {};
605-
if (!room || playerId !== room.excaliburHolder || room.excaliburUsed) return;
609+
if (!room || room.phase !== 'excalibur-usage') return;
610+
if (playerId !== room.excaliburHolder || room.excaliburUsed) return;
606611

607612
room.excaliburUsed = true;
608613
processMissionResult(room);
@@ -655,6 +660,7 @@ io.on("connection", (socket) => {
655660
} else {
656661
room.phase = 'team-proposal';
657662
room.proposedTeam = []; // Reset proposed team
663+
room.excaliburHolder = null; // novo líder faz nova atribuição
658664
room.currentLeaderIndex = (room.currentLeaderIndex + 1) % room.players.length;
659665
}
660666
}
@@ -1002,7 +1008,7 @@ function checkGameOver(room: Room) {
10021008

10031009
function saveMatchHistory(room: Room) {
10041010
const match: MatchRecord = {
1005-
id: Math.random().toString(36).substring(2, 9),
1011+
id: crypto.randomUUID(),
10061012
timestamp: new Date().toISOString(),
10071013
playerCount: room.players.length,
10081014
players: room.players.map(p => {

src/components/game/GameView.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { useState, useEffect } from 'react';
7-
import { useTranslation } from 'react-i18next';
7+
import { useTranslation, Trans } from 'react-i18next';
88
import { motion, AnimatePresence } from 'motion/react';
99
import {
1010
Users,
@@ -35,7 +35,7 @@ export const GameView = ({ room, me, isHost, onLeave }: { room: Room; me?: Playe
3535
const playerId = getPersistentId();
3636
const currentMission = room.missions[room.currentMissionIndex];
3737
const leader = room.players[room.currentLeaderIndex];
38-
const isLeader = playerId === leader.id;
38+
const isLeader = playerId === leader?.id;
3939
const [selectedTeam, setSelectedTeam] = useState<string[]>([]);
4040
const [targetMissionIndex, setTargetMissionIndex] = useState<number | null>(null);
4141
const [ladyResult, setLadyResult] = useState<{ targetName: string; loyalty: 'good' | 'evil' } | null>(null);
@@ -81,7 +81,7 @@ export const GameView = ({ room, me, isHost, onLeave }: { room: Room; me?: Playe
8181
{p.id === playerId && <span className="font-normal text-blue-300 ml-1">{t('app.me')}</span>}
8282
{!p.socketId && <span className="text-[8px] ml-1 text-red-400 uppercase font-bold">{t('app.offline')}</span>}
8383
</span>
84-
{showCrown && p.id === leader.id && <Crown size={14} className="text-[#ffd700] shrink-0" />}
84+
{showCrown && p.id === leader?.id && <Crown size={14} className="text-[#ffd700] shrink-0" />}
8585
</span>
8686
);
8787

@@ -198,7 +198,10 @@ export const GameView = ({ room, me, isHost, onLeave }: { room: Room; me?: Playe
198198
{ladyResult && (
199199
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl text-center space-y-2">
200200
<h4 className="text-xs uppercase tracking-widest text-[#ffd700] font-bold">{t('app.game.investigationResult')}</h4>
201-
<p className="text-sm" dangerouslySetInnerHTML={{ __html: t('app.game.investigationIs', { name: ladyResult.targetName }) }} />
201+
{/* Trans em vez de dangerouslySetInnerHTML: nome vem do jogador (XSS) */}
202+
<p className="text-sm">
203+
<Trans i18nKey="app.game.investigationIs" values={{ name: ladyResult.targetName }} components={{ b: <b /> }} />
204+
</p>
202205
<Badge team={ladyResult.loyalty}>{ladyResult.loyalty === 'good' ? t('app.game.loyal') : t('app.game.disloyal')}</Badge>
203206
</div>
204207
)}
@@ -430,7 +433,8 @@ export const GameView = ({ room, me, isHost, onLeave }: { room: Room; me?: Playe
430433
<div className="space-y-4">
431434
<p className="text-sm font-bold">{t('app.game.excaliburChoosePlayer')}</p>
432435
<div className="grid grid-cols-2 gap-2">
433-
{room.proposedTeam.map(id => (
436+
{/* Excalibur não pode inverter o próprio voto */}
437+
{room.proposedTeam.filter(id => id !== playerId).map(id => (
434438
<div key={id}>
435439
<Button
436440
variant="outline"
@@ -461,7 +465,9 @@ export const GameView = ({ room, me, isHost, onLeave }: { room: Room; me?: Playe
461465
{room.excaliburUsed && room.excaliburTarget && (
462466
<div className="p-3 bg-[#ffd700]/10 border border-[#ffd700]/30 rounded-xl max-w-xs mx-auto space-y-1">
463467
<p className="text-[10px] uppercase tracking-widest text-[#ffd700] font-bold">{t('app.game.excaliburUsed')}</p>
464-
<p className="text-xs" dangerouslySetInnerHTML={{ __html: t('app.game.excaliburRevealVote', { name: room.players.find(p => p.id === room.excaliburTarget)?.name }) }} />
468+
<p className="text-xs">
469+
<Trans i18nKey="app.game.excaliburRevealVote" values={{ name: room.players.find(p => p.id === room.excaliburTarget)?.name }} components={{ b: <b /> }} />
470+
</p>
465471
<div className="flex items-center justify-center gap-2">
466472
<span className="text-xl">{room.excaliburReveal === 'success' ? '🏆' : '💣'}</span>
467473
<span className={`font-bold ${room.excaliburReveal === 'success' ? 'text-blue-400' : 'text-red-400'}`}>

src/components/lobby/Home.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,16 @@ export const Home = () => {
3030

3131
const handleJoin = () => {
3232
if (!name || !roomCode) return alert(t('app.fillNameAndCode'));
33-
socket.emit('join-room', { roomCode: roomCode.toUpperCase(), playerName: name, playerId: getPersistentId(), sessionToken: getSessionToken() });
33+
socket.emit('join-room', { roomCode: roomCode.toUpperCase(), playerName: name, playerId: getPersistentId(), sessionToken: getSessionToken(roomCode.toUpperCase()) });
3434
};
3535

3636
useEffect(() => {
3737
const handleRoomCreated = ({ roomCode, sessionToken }: any) => {
38-
setSessionToken(sessionToken);
38+
setSessionToken(roomCode, sessionToken);
3939
navigate(`/room/${roomCode}`);
4040
};
4141
const handleJoined = ({ roomCode, sessionToken }: any) => {
42-
setSessionToken(sessionToken);
42+
setSessionToken(roomCode, sessionToken);
4343
navigate(`/room/${roomCode}`);
4444
};
4545
const handleError = ({ code, message }: { code?: string; message: string }) =>
@@ -104,7 +104,9 @@ export const Home = () => {
104104
<button
105105
onClick={() => {
106106
localStorage.removeItem('avalon_player_id');
107-
localStorage.removeItem('avalon_session_token');
107+
Object.keys(localStorage)
108+
.filter(k => k.startsWith('avalon_session_token'))
109+
.forEach(k => localStorage.removeItem(k));
108110
sessionStorage.removeItem('avalon_player_id');
109111
sessionStorage.removeItem('avalon_session_token');
110112
window.location.reload();

src/components/lobby/Room.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useParams, useNavigate } from 'react-router-dom';
88
import { useTranslation } from 'react-i18next';
99
import { motion, AnimatePresence } from 'motion/react';
1010
import { LogOut } from 'lucide-react';
11-
import { getPersistentId, getSessionToken } from '../../lib/session';
11+
import { getPersistentId, getSessionToken, setSessionToken } from '../../lib/session';
1212
import { useSocket } from '../../context/SocketContext';
1313
import { useSettings } from '../../context/SettingsContext';
1414
import { Layout } from '../ui/Layout';
@@ -43,10 +43,17 @@ export const Room = () => {
4343
};
4444

4545
const requestRoomInfo = () => {
46-
socket.emit('get-room-info', { roomCode: code?.toUpperCase(), playerId: getPersistentId(), sessionToken: getSessionToken() });
46+
const roomCode = code?.toUpperCase() ?? '';
47+
socket.emit('get-room-info', { roomCode, playerId: getPersistentId(), sessionToken: getSessionToken(roomCode) });
48+
};
49+
50+
// Sem salvar o token aqui, quem entra via QR/link não consegue reconectar
51+
const handleJoined = ({ roomCode, sessionToken }: { roomCode: string; sessionToken?: string }) => {
52+
setSessionToken(roomCode, sessionToken);
4753
};
4854

4955
socket.on('room-updated', handleRoomUpdate);
56+
socket.on('joined-room', handleJoined);
5057
socket.on('error', handleError);
5158
// Reconexão (wifi caiu, app voltou do background): sem isso o cliente
5259
// fica preso no último estado conhecido até a página ser recarregada
@@ -56,6 +63,7 @@ export const Room = () => {
5663

5764
return () => {
5865
socket.off('room-updated', handleRoomUpdate);
66+
socket.off('joined-room', handleJoined);
5967
socket.off('error', handleError);
6068
socket.off('connect', requestRoomInfo);
6169
};
@@ -64,7 +72,7 @@ export const Room = () => {
6472
const handleJoin = () => {
6573
if (!playerName) return alert(t('app.enterNameAlert'));
6674
setIsJoining(true);
67-
socket.emit('join-room', { roomCode: code?.toUpperCase(), playerName, playerId: getPersistentId(), sessionToken: getSessionToken() });
75+
socket.emit('join-room', { roomCode: code?.toUpperCase(), playerName, playerId: getPersistentId(), sessionToken: getSessionToken(code?.toUpperCase() ?? '') });
6876
};
6977

7078
if (!room) {
@@ -130,7 +138,7 @@ export const Room = () => {
130138
{room.phase === 'lobby' && <LobbyView room={room} isHost={isHost} onLeave={handleLeave} />}
131139
{room.phase === 'character-reveal' && <CharacterRevealView room={room} me={me} />}
132140
{room.phase === 'narration' && <NarrationView room={room} isHost={isHost} />}
133-
{(['team-proposal', 'team-voting', 'team-result', 'mission-voting', 'mission-result', 'assassination', 'game-over'].includes(room.phase)) && (
141+
{(['team-proposal', 'team-voting', 'team-result', 'mission-voting', 'excalibur-usage', 'mission-result', 'lady-of-the-lake', 'assassination', 'game-over'].includes(room.phase)) && (
134142
<GameView room={room} me={me} isHost={isHost} onLeave={handleLeave} />
135143
)}
136144
</AnimatePresence>

src/lib/session.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ export const getPersistentId = () => {
33
// (migra ids antigos que estavam em sessionStorage)
44
let id = localStorage.getItem('avalon_player_id') ?? sessionStorage.getItem('avalon_player_id');
55
if (!id) {
6-
id = Math.random().toString(36).substring(2, 15);
6+
id = crypto.randomUUID();
77
}
88
localStorage.setItem('avalon_player_id', id);
99
return id;
1010
};
1111

12-
export const getSessionToken = () =>
13-
localStorage.getItem('avalon_session_token') ?? sessionStorage.getItem('avalon_session_token');
14-
export const setSessionToken = (token: string | undefined) => {
15-
if (token) localStorage.setItem('avalon_session_token', token);
12+
// Token é por sala no servidor — chavear por código evita que entrar numa
13+
// segunda sala invalide a reconexão na primeira
14+
export const getSessionToken = (roomCode: string) =>
15+
localStorage.getItem(`avalon_session_token_${roomCode}`) ??
16+
localStorage.getItem('avalon_session_token'); // legado: token único global
17+
export const setSessionToken = (roomCode: string, token: string | undefined) => {
18+
if (token) localStorage.setItem(`avalon_session_token_${roomCode}`, token);
1619
};

0 commit comments

Comments
 (0)