Commit e0aedb73 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: auth double verify, delete cascade, queue timeout, spectate routing, shop owned

Phase 5: queue 60s timeout with retry prompt, match history accepts game_key,
spectate routes to correct game scene, shop shows "owned" indicator, profile
shows dash instead of 1200 for unrated players.

Phase 6: remove duplicate disconnectWatch calls (use match-session only),
fix auth.php double token verification with requireAuthUser(), cascade
delete related data on account deletion.

Phase 1: fix opponentRating using actual rating in live mode instead of
botElos lookup.

Fix achievements recursive mount — update in-place instead of re-calling
mountAchievements.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 574f803b
......@@ -214,11 +214,7 @@ function handleLogout(): void {
}
function handleMe(): void {
$token = requireAuth();
$user = verifyToken($token);
if (!$user) {
jsonError('Invalid token', 401);
}
$user = requireAuthUser();
jsonResponse([
'id' => $user['id'],
'email' => $user['email'] ?? null,
......@@ -228,12 +224,7 @@ function handleMe(): void {
}
function handleDeleteAccount(array $input): void {
$token = requireAuth();
$user = verifyToken($token);
if (!$user) {
jsonError('Invalid token', 401);
}
$user = requireAuthUser();
$userId = $user['id'];
$provider = $user['app_metadata']['provider'] ?? 'email';
......@@ -256,6 +247,10 @@ function handleDeleteAccount(array $input): void {
}
$sdb = supabaseService();
$sdb->delete('notifications', ['user_id' => 'eq.' . $userId]);
$sdb->delete('friendships', ['or' => "(user_id.eq.{$userId},friend_id.eq.{$userId})"]);
$sdb->delete('chat_messages', ['sender_id' => 'eq.' . $userId]);
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
$sdb->delete('profiles', ['id' => 'eq.' . $userId]);
$url = SUPABASE_AUTH . '/admin/users/' . $userId;
......
......@@ -45,6 +45,36 @@ function requireAuth(): string {
return $token;
}
function requireAuthUser(): array {
$token = getAuthToken();
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'Authentication required']);
exit;
}
$user = verifyToken($token);
if (!$user) {
http_response_code(401);
echo json_encode(['error' => 'Invalid or expired token']);
exit;
}
$userId = $user['id'] ?? null;
if ($userId) {
require_once __DIR__ . '/supabase.php';
$sdb = supabaseService();
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'is_banned,ban_expires_at', 'limit' => 1]);
if (!empty($profiles) && !isset($profiles['error']) && ($profiles[0]['is_banned'] ?? false)) {
$banExpires = $profiles[0]['ban_expires_at'] ?? null;
if (!$banExpires || strtotime($banExpires) > time()) {
http_response_code(403);
echo json_encode(['error' => 'Account banned', 'ban_expires_at' => $banExpires]);
exit;
}
}
}
return $user;
}
function verifyToken(string $token): ?array {
$url = SUPABASE_AUTH . '/user';
$ch = curl_init($url);
......
......@@ -32,7 +32,8 @@ export function mountGame(el, params) {
capturedByPlayer: [], capturedByOpponent: [],
moveHistory: [], botThinking: false,
tournamentId: params.tournamentId || null,
tournamentRound: params.tournamentRound || null
tournamentRound: params.tournamentRound || null,
opponentRating: params.opponentRating || null
};
engine.create();
......@@ -309,8 +310,6 @@ export function mountGame(el, params) {
if (opponentId) {
fetchAndRenderOpponent(el, opponentId);
}
mp.startDisconnectWatch(matchId, 'chess', 60000);
}
// Mount emote panel right after the player bar (second .chess-bar)
......@@ -903,6 +902,9 @@ function fetchAndRenderOpponent(el, oppId) {
if (avatarEl && opp.avatar_url) {
avatarEl.innerHTML = `<img src="${opp.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`;
}
if (opp.chess_rating && gameState) {
gameState.opponentRating = opp.chess_rating;
}
}
}).catch(() => {});
}
......@@ -922,9 +924,10 @@ function endGame(result, reason) {
const coins = result === 'win' ? 50 : result === 'draw' ? 20 : 10;
const xp = gameState.moveCount * 2;
// Submit game result to backend for rating calculation
const botElos = { amina: 500, tarek: 900, nour: 1100, omar: 1300, layla: 1500, ziad: 1700, grandmaster: 2100 };
const opponentRating = botElos[gameState.botId] || 1200;
const opponentRating = gameState.mode === 'live'
? (gameState.opponentRating || 1200)
: (botElos[gameState.botId] || 1200);
let navigated = false;
function navigateToResult(ratingChange, ratingBefore, ratingAfter) {
......
......@@ -5,7 +5,8 @@ import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
export async function mountHistory(el) {
export async function mountHistory(el, params = {}) {
const gameKey = params.game || 'chess';
el.innerHTML = `
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;align-items:center;gap:12px;">
......@@ -23,7 +24,7 @@ export async function mountHistory(el) {
el.querySelector('#back-btn').addEventListener('click', () => { audio.play('click'); scene.pop(); });
try {
const data = await net.get('match-history.php', { limit: 20 });
const data = await net.get('match-history.php', { limit: 20, game_key: gameKey });
const matches = data.matches || [];
renderHistory(el, matches);
} catch (e) {
......
......@@ -351,7 +351,6 @@ function setupLiveMultiplayer(el, matchId) {
});
fetchOpponentProfile(el);
mp.startDisconnectWatch?.(matchId, 'domino', 60000);
startHeartbeat();
if (state.myPlayerIndex === 0) {
......
......@@ -225,7 +225,6 @@ export function mountGame(el, params) {
// Only use matchLive for session persistence (tab recovery), not for polling
localStorage.setItem('el3ab_active_match', JSON.stringify({ matchId, gameType: 'ludo', timestamp: Date.now() }));
mp.startDisconnectWatch(matchId, 'ludo', 60000);
mp.onEmoteReceived((emote) => {
const senderIdx = emote.from === store.get('auth.userId') ? myPlayerIndex : findPlayerByUserId(emote.from);
showEmoteBubble(el, senderIdx, emote.key);
......
......@@ -2,10 +2,13 @@ import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.js';
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js';
const QUEUE_TIMEOUT_S = 60;
let unsub = null;
let timer = null;
let timeoutId = null;
let activeParams = null;
export function unmountQueue() {
......@@ -46,6 +49,8 @@ export function mountQueue(el, params) {
joinQueue(params);
timeoutId = setTimeout(() => onQueueTimeout(el, params), QUEUE_TIMEOUT_S * 1000);
el.querySelector('#cancel-btn').addEventListener('click', () => {
audio.play('click');
leaveQueue(params);
......@@ -54,6 +59,35 @@ export function mountQueue(el, params) {
});
}
async function onQueueTimeout(el, params) {
cleanup();
const retry = await modal.confirm(
t('play.retry_or_cancel') || 'No opponent found. Keep searching?',
{
title: t('play.no_match_found') || 'No match',
confirmText: t('play.retry') || 'Retry',
cancelText: t('play.cancel') || 'Cancel'
}
);
if (retry) {
let seconds = 0;
const timerEl = el.querySelector('#queue-timer');
if (timerEl) timerEl.textContent = '0:00';
timer = setInterval(() => {
seconds++;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (timerEl) timerEl.textContent = `${m}:${s.toString().padStart(2, '0')}`;
}, 1000);
joinQueue(params);
timeoutId = setTimeout(() => onQueueTimeout(el, params), QUEUE_TIMEOUT_S * 1000);
} else {
leaveQueue(params);
activeParams = null;
scene.pop();
}
}
async function joinQueue(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : params.game === 'backgammon' ? 'backgammon-match.php' : 'matchmaking.php';
try {
......@@ -115,4 +149,5 @@ async function leaveQueue(params) {
function cleanup() {
if (timer) { clearInterval(timer); timer = null; }
if (unsub) { clearInterval(unsub); unsub = null; }
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
}
......@@ -354,8 +354,10 @@ async function checkActiveMatch(el, playerId) {
scene.push('chess-spectate', { matchId: data.match_id });
} else if (data.game_key === 'ludo') {
scene.push('ludo-game', { matchId: data.match_id, mode: 'spectate' });
} else {
scene.push('chess-spectate', { matchId: data.match_id });
} else if (data.game_key === 'domino') {
scene.push('domino-game', { matchId: data.match_id, mode: 'spectate' });
} else if (data.game_key === 'backgammon') {
scene.push('backgammon-game', { matchId: data.match_id, mode: 'spectate' });
}
});
}
......@@ -475,10 +477,11 @@ function compressAvatar(file) {
}
function renderRating(label, rating) {
const display = rating ? rating : '—';
return `
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:13px;color:var(--text-secondary);">${label}</span>
<span style="font-size:15px;font-weight:700;font-family:var(--font-lat);">${rating || 1200}</span>
<span style="font-size:15px;font-weight:700;font-family:var(--font-lat);${!rating ? 'color:var(--text-muted);' : ''}">${display}</span>
</div>
`;
}
......
......@@ -32,13 +32,16 @@ export async function mountAchievements(el) {
}
} catch (e) {}
// Also trigger a check to update progress
net.post('achievements.php', { action: 'check' }).then(res => {
if (res && res.newly_completed && res.newly_completed.length > 0) {
juice.hapticSuccess();
audio.play('reward');
// Re-mount to show updated state
mountAchievements(el);
res.newly_completed.forEach(id => {
const a = achievements.find(x => x.id === id);
if (a) { a.completed = true; a.progress = a.target; }
});
stats.completed = achievements.filter(a => a.completed).length;
render(el, achievements, stats);
}
}).catch(() => {});
......
......@@ -36,15 +36,15 @@ function renderItems(el, items) {
}
grid.innerHTML = items.map(item => `
<div class="card shop-item" data-id="${item.id}" style="cursor:pointer;padding:var(--s-3);border-color:${RARITY_COLORS[item.rarity] || 'var(--border)'}33;">
<div class="card shop-item" data-id="${item.id}" style="cursor:pointer;padding:var(--s-3);border-color:${RARITY_COLORS[item.rarity] || 'var(--border)'}33;${item.owned ? 'opacity:0.7;' : ''}position:relative;">
${item.owned ? `<div style="position:absolute;top:6px;right:6px;background:var(--success);color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;">Owned</div>` : ''}
<div style="height:60px;background:var(--bg-elevated);border-radius:var(--r-sm);margin-bottom:var(--s-2);display:flex;align-items:center;justify-content:center;font-size:24px;">
${emoji('art', '🎨', 24)}
</div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px;">${item.name || item.id}</div>
<div style="font-size:10px;color:${RARITY_COLORS[item.rarity] || 'var(--text-muted)'};margin-bottom:var(--s-1);">${item.rarity || 'common'}</div>
<div style="font-size:12px;font-weight:700;color:var(--gold);">
${item.price_coins ? `${item.price_coins} ${emoji('coin', '🪙', 14)}` : ''}
${item.price_gems ? `${item.price_gems} ${emoji('gem', '💎', 14)}` : ''}
${item.owned ? '' : (item.price_coins ? `${item.price_coins} ${emoji('coin', '🪙', 14)}` : '') + (item.price_gems ? ` ${item.price_gems} ${emoji('gem', '💎', 14)}` : '')}
</div>
</div>
`).join('');
......@@ -54,7 +54,7 @@ function renderItems(el, items) {
audio.play('click');
const id = card.dataset.id;
const item = items.find(i => i.id === id);
if (item) purchasePrompt(el, item);
if (item && !item.owned) purchasePrompt(el, item);
});
});
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment