diff --git a/src/extension/startgg.ts b/src/extension/startgg.ts index 1a06786..04f8c37 100644 --- a/src/extension/startgg.ts +++ b/src/extension/startgg.ts @@ -1,4 +1,4 @@ -import { createServer, type Server } from 'node:http'; +import { createServer, type Server, type ServerResponse } from 'node:http'; import { randomUUID } from 'node:crypto'; import { getData, type CountryRecord } from 'country-list'; import { nodecg } from './util/nodecg.js'; @@ -64,6 +64,27 @@ interface OAuthTokenResponse { const oauthSessions = new Map(); let oauthCallbackServer: Server | null = null; +const getStringProp = (payload: unknown, key: string): string => { + if (typeof payload !== 'object' || payload === null || !(key in payload)) { + return ''; + } + + const value = (payload as Record)[key]; + return typeof value === 'string' ? value.trim() : String(value || '').trim(); +}; + +const updateOAuthSession = (sessionId: string, update: Partial) => { + const session = oauthSessions.get(sessionId); + if (!session) { + return; + } + + oauthSessions.set(sessionId, { + ...session, + ...update, + }); +}; + const requestStartGG = async (query: string, variables: Record, token: string): Promise => { const response = await fetch(STARTGG_ENDPOINT, { method: 'POST', @@ -75,10 +96,16 @@ const requestStartGG = async (query: string, variables: Record; + try { + payload = (await response.json()) as StartGGGraphQLResponse; + } catch { + throw new Error('Invalid JSON response from start.gg'); } - const payload = (await response.json()) as StartGGGraphQLResponse; if (payload.errors?.length) { throw new Error(payload.errors[0]?.message || 'Unknown start.gg error'); } @@ -137,16 +164,19 @@ const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPor const cleanupExpiredOAuthSessions = () => { const now = Date.now(); - oauthSessions.forEach((session) => { + oauthSessions.forEach((session, sessionId) => { if (session.expiresAt <= now && session.status === 'pending') { - oauthSessions.set(session.sessionId, { - ...session, - status: 'expired', - }); + updateOAuthSession(sessionId, { status: 'expired' }); } }); }; +const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderCallbackHtml(title, message)); +}; + const renderCallbackHtml = (title: string, message: string) => ` @@ -252,68 +282,41 @@ const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => { const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state); if (!session) { - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('OAuth inválido', 'No se encontró una sesión activa para esta autorización.')); + respondWithCallbackHtml(res, 400, 'OAuth inválido', 'No se encontró una sesión activa para esta autorización.'); return; } if (session.expiresAt <= Date.now()) { - oauthSessions.set(session.sessionId, { - ...session, - status: 'expired', - }); - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('Sesión expirada', 'La sesión OAuth expiró. Vuelve a iniciar el proceso desde Scoreko.')); + updateOAuthSession(session.sessionId, { status: 'expired' }); + respondWithCallbackHtml(res, 400, 'Sesión expirada', 'La sesión OAuth expiró. Vuelve a iniciar el proceso desde Scoreko.'); return; } if (error) { - oauthSessions.set(session.sessionId, { - ...session, - status: 'error', - error, - }); - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('OAuth cancelado', `start.gg devolvió el error: ${error}`)); + updateOAuthSession(session.sessionId, { status: 'error', error }); + respondWithCallbackHtml(res, 400, 'OAuth cancelado', `start.gg devolvió el error: ${error}`); return; } if (!code) { - oauthSessions.set(session.sessionId, { - ...session, + updateOAuthSession(session.sessionId, { status: 'error', error: 'Missing authorization code', }); - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('OAuth incompleto', 'No se recibió un código de autorización.')); + respondWithCallbackHtml(res, 400, 'OAuth incompleto', 'No se recibió un código de autorización.'); return; } void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig) .then((token) => { - oauthSessions.set(session.sessionId, { - ...session, - status: 'completed', - token, - error: undefined, - }); + updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined }); }) .catch((exchangeError) => { const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code'; - oauthSessions.set(session.sessionId, { - ...session, - status: 'error', - error: message, - }); + updateOAuthSession(session.sessionId, { status: 'error', error: message }); }); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('Autorización recibida', 'Se recibió tu autorización. Finalizando el login en segundo plano...')); + respondWithCallbackHtml(res, 200, 'Autorización recibida', 'Se recibió tu autorización. Finalizando el login en segundo plano...'); }); await new Promise((resolve, reject) => { @@ -371,9 +374,7 @@ nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { cleanupExpiredOAuthSessions(); - const sessionId = typeof payload === 'object' && payload !== null && 'sessionId' in payload - ? String((payload as { sessionId?: string }).sessionId || '').trim() - : ''; + const sessionId = getStringProp(payload, 'sessionId'); if (!sessionId) { sendAck(ack, 'Missing OAuth session id'); @@ -394,9 +395,7 @@ nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { }); nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => { - const token = typeof payload === 'object' && payload !== null && 'token' in payload - ? String((payload as { token?: string }).token || '').trim() - : ''; + const token = getStringProp(payload, 'token'); if (!token) { sendAck(ack, 'Missing start.gg API token'); @@ -443,12 +442,8 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) }); nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => { - const candidate = typeof payload === 'object' && payload !== null ? payload as { - token?: string; - slug?: string; - } : {}; - const token = String(candidate.token || '').trim(); - const slug = String(candidate.slug || '').trim(); + const token = getStringProp(payload, 'token'); + const slug = getStringProp(payload, 'slug'); if (!token) { sendAck(ack, 'Missing start.gg API token'); @@ -485,7 +480,7 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) try { let currentPage = 1; let totalPages = 1; - const playersMap: Record = {}; + const playersMap = new Map(); while (currentPage <= totalPages) { const data = await requestStartGG<{ @@ -514,7 +509,8 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) throw new Error('Tournament not found'); } - totalPages = Math.max(data.tournament.participants.pageInfo.totalPages || 1, 1); + const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages); + totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1; data.tournament.participants.nodes.forEach((participant) => { const playerId = String(participant.id); @@ -523,20 +519,20 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) return; } const country = resolveCountryCodeFromStartGG(participant.user?.location?.country); - playersMap[playerId] = { + playersMap.set(playerId, { id: playerId, gamertag, name: gamertag, team: (participant.prefix || '').trim(), country, twitter: '', - }; + }); }); currentPage += 1; } - sendAck(ack, null, Object.values(playersMap)); + sendAck(ack, null, Array.from(playersMap.values())); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error while importing players'; sendAck(ack, message);