Merge pull request #93 from Pandipipas/refactor-startgg-function

refactor(startgg): limpiar flujo OAuth y parsing de payloads
This commit is contained in:
Pandipipas
2026-02-17 18:23:24 +01:00
committed by GitHub
2 changed files with 94 additions and 94 deletions
+33 -29
View File
@@ -275,13 +275,13 @@ const checkOAuthStatus = async () => {
oauthLoading.value = false; oauthLoading.value = false;
clearOAuthPolling(); clearOAuthPolling();
oauthSessionId.value = ''; oauthSessionId.value = '';
tournamentsError.value = status.error || 'No se pudo completar el login OAuth con start.gg.'; tournamentsError.value = status.error || 'Could not complete OAuth login with start.gg.';
} }
} catch (error) { } catch (error) {
oauthLoading.value = false; oauthLoading.value = false;
clearOAuthPolling(); clearOAuthPolling();
oauthSessionId.value = ''; oauthSessionId.value = '';
tournamentsError.value = error instanceof Error ? error.message : 'No se pudo verificar el estado OAuth.'; tournamentsError.value = error instanceof Error ? error.message : 'Could not verify OAuth status.';
} }
}; };
@@ -300,7 +300,7 @@ const connectWithStartGGOAuth = async () => {
}, 1500); }, 1500);
} catch (error) { } catch (error) {
oauthLoading.value = false; oauthLoading.value = false;
tournamentsError.value = error instanceof Error ? error.message : 'No se pudo iniciar OAuth con start.gg.'; tournamentsError.value = error instanceof Error ? error.message : 'Could not start OAuth with start.gg.';
} }
}; };
@@ -325,7 +325,7 @@ const saveManualToken = () => {
const loadRecentTournaments = async () => { const loadRecentTournaments = async () => {
const token = startGGToken.value.trim(); const token = startGGToken.value.trim();
if (!token) { if (!token) {
tournamentsError.value = 'Añade tu token de start.gg para cargar torneos.'; tournamentsError.value = 'Add your start.gg token to load tournaments.';
recentTournaments.value = []; recentTournaments.value = [];
return; return;
} }
@@ -338,10 +338,10 @@ const loadRecentTournaments = async () => {
}); });
recentTournaments.value = tournaments; recentTournaments.value = tournaments;
if (!tournaments.length) { if (!tournaments.length) {
tournamentsError.value = 'No hay torneos recientes para esta cuenta.'; tournamentsError.value = 'There are no recent tournaments for this account.';
} }
} catch (error) { } catch (error) {
tournamentsError.value = error instanceof Error ? error.message : 'No se pudieron cargar torneos.'; tournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.';
recentTournaments.value = []; recentTournaments.value = [];
} finally { } finally {
loadingTournaments.value = false; loadingTournaments.value = false;
@@ -365,7 +365,7 @@ const openStartGGImportDialog = async (tournament: StartGGTournament) => {
startGGPlayers.value = importedPlayers; startGGPlayers.value = importedPlayers;
selectedStartGGPlayerIds.value = importedPlayers.map((player) => player.id); selectedStartGGPlayerIds.value = importedPlayers.map((player) => player.id);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'No se pudieron cargar jugadores'; const message = error instanceof Error ? error.message : 'Could not load players';
window.alert(message); window.alert(message);
isImportDialogOpen.value = false; isImportDialogOpen.value = false;
} finally { } finally {
@@ -599,7 +599,7 @@ onBeforeUnmount(() => {
<span>StartGG</span> <span>StartGG</span>
</div> </div>
<div class="text-caption q-mb-md"> <div class="text-caption q-mb-md">
Conecta por OAuth (recomendado) o pega tu token personal para cargar tus torneos creados o donde eres admin. Si aparece "Client authentication failed", revisa que en config uses el Client ID/Secret de un OAuth App de start.gg. Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App.
</div> </div>
<div class="row q-col-gutter-sm items-center"> <div class="row q-col-gutter-sm items-center">
<div class="col-auto"> <div class="col-auto">
@@ -607,7 +607,7 @@ onBeforeUnmount(() => {
v-if="!hasStartGGTokenConfigured" v-if="!hasStartGGTokenConfigured"
color="primary" color="primary"
icon="login" icon="login"
label="Conectar con start.gg" label="Connect with start.gg"
:loading="oauthLoading" :loading="oauthLoading"
@click="connectWithStartGGOAuth" @click="connectWithStartGGOAuth"
/> />
@@ -616,7 +616,7 @@ onBeforeUnmount(() => {
outline outline
color="positive" color="positive"
icon="check_circle" icon="check_circle"
label="Conectado" label="Connected"
class="startgg-connected-btn" class="startgg-connected-btn"
@click="openManualTokenDialog" @click="openManualTokenDialog"
/> />
@@ -626,7 +626,7 @@ onBeforeUnmount(() => {
outline outline
color="white" color="white"
icon="vpn_key" icon="vpn_key"
label="Usar API personal" label="Use personal API"
@click="openManualTokenDialog" @click="openManualTokenDialog"
/> />
</div> </div>
@@ -663,7 +663,7 @@ onBeforeUnmount(() => {
input-debounce="0" input-debounce="0"
clearable clearable
dense dense
label="Torneo" label="Tournament"
class="players-underlined-field" class="players-underlined-field"
@filter="filterTournaments" @filter="filterTournaments"
> >
@@ -686,9 +686,13 @@ onBeforeUnmount(() => {
<QBtn <QBtn
color="primary" color="primary"
unelevated unelevated
label="Importar jugadores" round
icon="person_add"
aria-label="Import players"
@click="openSelectedTournamentImportDialog" @click="openSelectedTournamentImportDialog"
/> >
<QTooltip>Import players</QTooltip>
</QBtn>
</div> </div>
</div> </div>
</QCard> </QCard>
@@ -700,24 +704,24 @@ onBeforeUnmount(() => {
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">
API personal de start.gg Personal start.gg API
</div> </div>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
<QCardSection> <QCardSection>
<div class="text-body2 q-mb-sm"> <div class="text-body2 q-mb-sm">
Si OAuth falla, puedes crear tu token personal manualmente con estos pasos: If OAuth fails, you can create your personal token manually with these steps:
</div> </div>
<ol class="q-pl-md q-mb-md manual-token-steps"> <ol class="q-pl-md q-mb-md manual-token-steps">
<li>Ir a https://start.gg/admin/profile/developer</li> <li>Go to https://start.gg/admin/profile/developer</li>
<li>Iniciar sesión con tu cuenta</li> <li>Sign in with your account</li>
<li>De los 3 access tokens, clicar en <strong>Third Party</strong></li> <li>From the 3 access tokens, click <strong>Third Party</strong></li>
<li>Crear uno nuevo y cubrir la descripción con el nombre que quieras</li> <li>Create a new one and fill the description with any name you want</li>
<li>Copiar el token generado y pegarlo en Scoreko</li> <li>Copy the generated token and paste it into Scoreko</li>
</ol> </ol>
<QInput <QInput
v-model="manualTokenDraft" v-model="manualTokenDraft"
label="Pega tu token personal" label="Paste your personal token"
dense dense
outlined outlined
type="password" type="password"
@@ -727,19 +731,19 @@ onBeforeUnmount(() => {
<QCardActions align="right"> <QCardActions align="right">
<QBtn <QBtn
flat flat
label="Cancelar" label="Cancel"
color="secondary" color="secondary"
@click="isManualTokenDialogOpen = false" @click="isManualTokenDialogOpen = false"
/> />
<QBtn <QBtn
flat flat
color="negative" color="negative"
label="Borrar token" label="Delete token"
@click="manualTokenDraft = ''; saveManualToken()" @click="manualTokenDraft = ''; saveManualToken()"
/> />
<QBtn <QBtn
color="primary" color="primary"
label="Guardar token" label="Save token"
@click="saveManualToken" @click="saveManualToken"
/> />
</QCardActions> </QCardActions>
@@ -750,7 +754,7 @@ onBeforeUnmount(() => {
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">
Importar desde {{ importingTournament?.name || 'start.gg' }} Import from {{ importingTournament?.name || 'start.gg' }}
</div> </div>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
@@ -760,7 +764,7 @@ onBeforeUnmount(() => {
class="row items-center q-gutter-sm" class="row items-center q-gutter-sm"
> >
<QSpinner /> <QSpinner />
<span>Cargando inscritos...</span> <span>Loading participants...</span>
</div> </div>
<div v-else> <div v-else>
<QOptionGroup <QOptionGroup
@@ -777,13 +781,13 @@ onBeforeUnmount(() => {
<QCardActions align="right"> <QCardActions align="right">
<QBtn <QBtn
flat flat
label="Cancelar" label="Cancel"
color="secondary" color="secondary"
@click="isImportDialogOpen = false" @click="isImportDialogOpen = false"
/> />
<QBtn <QBtn
color="primary" color="primary"
label="Importar seleccionados" label="Import selected"
:disable="!selectedStartGGPlayerIds.length" :disable="!selectedStartGGPlayerIds.length"
@click="importSelectedStartGGPlayers" @click="importSelectedStartGGPlayers"
/> />
+61 -65
View File
@@ -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 { randomUUID } from 'node:crypto';
import { getData, type CountryRecord } from 'country-list'; import { getData, type CountryRecord } from 'country-list';
import { nodecg } from './util/nodecg.js'; import { nodecg } from './util/nodecg.js';
@@ -64,6 +64,27 @@ interface OAuthTokenResponse {
const oauthSessions = new Map<string, OAuthSession>(); const oauthSessions = new Map<string, OAuthSession>();
let oauthCallbackServer: Server | null = null; 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<string, unknown>)[key];
return typeof value === 'string' ? value.trim() : String(value || '').trim();
};
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
const session = oauthSessions.get(sessionId);
if (!session) {
return;
}
oauthSessions.set(sessionId, {
...session,
...update,
});
};
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => { const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, { const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST', method: 'POST',
@@ -75,10 +96,16 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`start.gg responded with ${response.status}`); throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim());
}
let payload: StartGGGraphQLResponse<T>;
try {
payload = (await response.json()) as StartGGGraphQLResponse<T>;
} catch {
throw new Error('Invalid JSON response from start.gg');
} }
const payload = (await response.json()) as StartGGGraphQLResponse<T>;
if (payload.errors?.length) { if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message || 'Unknown start.gg error'); 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 cleanupExpiredOAuthSessions = () => {
const now = Date.now(); const now = Date.now();
oauthSessions.forEach((session) => { oauthSessions.forEach((session, sessionId) => {
if (session.expiresAt <= now && session.status === 'pending') { if (session.expiresAt <= now && session.status === 'pending') {
oauthSessions.set(session.sessionId, { updateOAuthSession(sessionId, { status: 'expired' });
...session,
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) => `<!doctype html> const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
<html lang="es"> <html lang="es">
<head> <head>
@@ -163,7 +193,7 @@ const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
<div class="box"> <div class="box">
<h2>${title}</h2> <h2>${title}</h2>
<p>${message}</p> <p>${message}</p>
<p>Puedes cerrar esta pestaña y volver a Scoreko.</p> <p>You can close this tab and return to Scoreko.</p>
</div> </div>
</body> </body>
</html>`; </html>`;
@@ -252,68 +282,41 @@ const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state); const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state);
if (!session) { if (!session) {
res.statusCode = 400; respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
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.'));
return; return;
} }
if (session.expiresAt <= Date.now()) { if (session.expiresAt <= Date.now()) {
oauthSessions.set(session.sessionId, { updateOAuthSession(session.sessionId, { status: 'expired' });
...session, respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
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.'));
return; return;
} }
if (error) { if (error) {
oauthSessions.set(session.sessionId, { updateOAuthSession(session.sessionId, { status: 'error', error });
...session, respondWithCallbackHtml(res, 400, 'OAuth canceled', `start.gg returned this error: ${error}`);
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}`));
return; return;
} }
if (!code) { if (!code) {
oauthSessions.set(session.sessionId, { updateOAuthSession(session.sessionId, {
...session,
status: 'error', status: 'error',
error: 'Missing authorization code', error: 'Missing authorization code',
}); });
res.statusCode = 400; respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml('OAuth incompleto', 'No se recibió un código de autorización.'));
return; return;
} }
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig) void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
.then((token) => { .then((token) => {
oauthSessions.set(session.sessionId, { updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
...session,
status: 'completed',
token,
error: undefined,
});
}) })
.catch((exchangeError) => { .catch((exchangeError) => {
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code'; const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
oauthSessions.set(session.sessionId, { updateOAuthSession(session.sessionId, { status: 'error', error: message });
...session,
status: 'error',
error: message,
});
}); });
res.statusCode = 200; respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
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...'));
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -330,14 +333,14 @@ const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig(); const oauthConfig = getOAuthConfig();
if (!oauthConfig) { if (!oauthConfig) {
sendAck(ack, 'OAuth no está configurado en esta instalación (faltan startggClientId/startggClientSecret). Usa el Client ID y Client Secret del OAuth app de start.gg.'); sendAck(ack, 'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.');
return; return;
} }
try { try {
await ensureOAuthCallbackServer(oauthConfig); await ensureOAuthCallbackServer(oauthConfig);
} catch (serverError) { } catch (serverError) {
const message = serverError instanceof Error ? serverError.message : 'No se pudo iniciar el callback OAuth local'; const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
sendAck(ack, message); sendAck(ack, message);
return; return;
} }
@@ -371,9 +374,7 @@ nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) =>
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions(); cleanupExpiredOAuthSessions();
const sessionId = typeof payload === 'object' && payload !== null && 'sessionId' in payload const sessionId = getStringProp(payload, 'sessionId');
? String((payload as { sessionId?: string }).sessionId || '').trim()
: '';
if (!sessionId) { if (!sessionId) {
sendAck(ack, 'Missing OAuth session id'); 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) => { nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = typeof payload === 'object' && payload !== null && 'token' in payload const token = getStringProp(payload, 'token');
? String((payload as { token?: string }).token || '').trim()
: '';
if (!token) { if (!token) {
sendAck(ack, 'Missing start.gg API 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) => { nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
const candidate = typeof payload === 'object' && payload !== null ? payload as { const token = getStringProp(payload, 'token');
token?: string; const slug = getStringProp(payload, 'slug');
slug?: string;
} : {};
const token = String(candidate.token || '').trim();
const slug = String(candidate.slug || '').trim();
if (!token) { if (!token) {
sendAck(ack, 'Missing start.gg API token'); sendAck(ack, 'Missing start.gg API token');
@@ -485,7 +480,7 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
try { try {
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
const playersMap: Record<string, ImportedPlayer> = {}; const playersMap = new Map<string, ImportedPlayer>();
while (currentPage <= totalPages) { while (currentPage <= totalPages) {
const data = await requestStartGG<{ const data = await requestStartGG<{
@@ -514,7 +509,8 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
throw new Error('Tournament not found'); 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) => { data.tournament.participants.nodes.forEach((participant) => {
const playerId = String(participant.id); const playerId = String(participant.id);
@@ -523,20 +519,20 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
return; return;
} }
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country); const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
playersMap[playerId] = { playersMap.set(playerId, {
id: playerId, id: playerId,
gamertag, gamertag,
name: gamertag, name: gamertag,
team: (participant.prefix || '').trim(), team: (participant.prefix || '').trim(),
country, country,
twitter: '', twitter: '',
}; });
}); });
currentPage += 1; currentPage += 1;
} }
sendAck(ack, null, Object.values(playersMap)); sendAck(ack, null, Array.from(playersMap.values()));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while importing players'; const message = error instanceof Error ? error.message : 'Unknown error while importing players';
sendAck(ack, message); sendAck(ack, message);