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;
clearOAuthPolling();
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) {
oauthLoading.value = false;
clearOAuthPolling();
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);
} catch (error) {
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 token = startGGToken.value.trim();
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 = [];
return;
}
@@ -338,10 +338,10 @@ const loadRecentTournaments = async () => {
});
recentTournaments.value = tournaments;
if (!tournaments.length) {
tournamentsError.value = 'No hay torneos recientes para esta cuenta.';
tournamentsError.value = 'There are no recent tournaments for this account.';
}
} 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 = [];
} finally {
loadingTournaments.value = false;
@@ -365,7 +365,7 @@ const openStartGGImportDialog = async (tournament: StartGGTournament) => {
startGGPlayers.value = importedPlayers;
selectedStartGGPlayerIds.value = importedPlayers.map((player) => player.id);
} 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);
isImportDialogOpen.value = false;
} finally {
@@ -599,7 +599,7 @@ onBeforeUnmount(() => {
<span>StartGG</span>
</div>
<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 class="row q-col-gutter-sm items-center">
<div class="col-auto">
@@ -607,7 +607,7 @@ onBeforeUnmount(() => {
v-if="!hasStartGGTokenConfigured"
color="primary"
icon="login"
label="Conectar con start.gg"
label="Connect with start.gg"
:loading="oauthLoading"
@click="connectWithStartGGOAuth"
/>
@@ -616,7 +616,7 @@ onBeforeUnmount(() => {
outline
color="positive"
icon="check_circle"
label="Conectado"
label="Connected"
class="startgg-connected-btn"
@click="openManualTokenDialog"
/>
@@ -626,7 +626,7 @@ onBeforeUnmount(() => {
outline
color="white"
icon="vpn_key"
label="Usar API personal"
label="Use personal API"
@click="openManualTokenDialog"
/>
</div>
@@ -663,7 +663,7 @@ onBeforeUnmount(() => {
input-debounce="0"
clearable
dense
label="Torneo"
label="Tournament"
class="players-underlined-field"
@filter="filterTournaments"
>
@@ -686,9 +686,13 @@ onBeforeUnmount(() => {
<QBtn
color="primary"
unelevated
label="Importar jugadores"
round
icon="person_add"
aria-label="Import players"
@click="openSelectedTournamentImportDialog"
/>
>
<QTooltip>Import players</QTooltip>
</QBtn>
</div>
</div>
</QCard>
@@ -700,24 +704,24 @@ onBeforeUnmount(() => {
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
API personal de start.gg
Personal start.gg API
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<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>
<ol class="q-pl-md q-mb-md manual-token-steps">
<li>Ir a https://start.gg/admin/profile/developer</li>
<li>Iniciar sesión con tu cuenta</li>
<li>De los 3 access tokens, clicar en <strong>Third Party</strong></li>
<li>Crear uno nuevo y cubrir la descripción con el nombre que quieras</li>
<li>Copiar el token generado y pegarlo en Scoreko</li>
<li>Go to https://start.gg/admin/profile/developer</li>
<li>Sign in with your account</li>
<li>From the 3 access tokens, click <strong>Third Party</strong></li>
<li>Create a new one and fill the description with any name you want</li>
<li>Copy the generated token and paste it into Scoreko</li>
</ol>
<QInput
v-model="manualTokenDraft"
label="Pega tu token personal"
label="Paste your personal token"
dense
outlined
type="password"
@@ -727,19 +731,19 @@ onBeforeUnmount(() => {
<QCardActions align="right">
<QBtn
flat
label="Cancelar"
label="Cancel"
color="secondary"
@click="isManualTokenDialogOpen = false"
/>
<QBtn
flat
color="negative"
label="Borrar token"
label="Delete token"
@click="manualTokenDraft = ''; saveManualToken()"
/>
<QBtn
color="primary"
label="Guardar token"
label="Save token"
@click="saveManualToken"
/>
</QCardActions>
@@ -750,7 +754,7 @@ onBeforeUnmount(() => {
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Importar desde {{ importingTournament?.name || 'start.gg' }}
Import from {{ importingTournament?.name || 'start.gg' }}
</div>
</QCardSection>
<QSeparator />
@@ -760,7 +764,7 @@ onBeforeUnmount(() => {
class="row items-center q-gutter-sm"
>
<QSpinner />
<span>Cargando inscritos...</span>
<span>Loading participants...</span>
</div>
<div v-else>
<QOptionGroup
@@ -777,13 +781,13 @@ onBeforeUnmount(() => {
<QCardActions align="right">
<QBtn
flat
label="Cancelar"
label="Cancel"
color="secondary"
@click="isImportDialogOpen = false"
/>
<QBtn
color="primary"
label="Importar seleccionados"
label="Import selected"
:disable="!selectedStartGGPlayerIds.length"
@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 { getData, type CountryRecord } from 'country-list';
import { nodecg } from './util/nodecg.js';
@@ -64,6 +64,27 @@ interface OAuthTokenResponse {
const oauthSessions = new Map<string, OAuthSession>();
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 response = await fetch(STARTGG_ENDPOINT, {
method: 'POST',
@@ -75,10 +96,16 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
});
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) {
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) => `<!doctype html>
<html lang="es">
<head>
@@ -163,7 +193,7 @@ const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
<div class="box">
<h2>${title}</h2>
<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>
</body>
</html>`;
@@ -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, 'Invalid OAuth', 'No active session was found for this authorization.');
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, 'Session expired', 'The OAuth session expired. Start the process again from 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 canceled', `start.gg returned this 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, 'Incomplete OAuth', 'No authorization code was received.');
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, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
});
await new Promise<void>((resolve, reject) => {
@@ -330,14 +333,14 @@ const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig();
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;
}
try {
await ensureOAuthCallbackServer(oauthConfig);
} 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);
return;
}
@@ -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<string, ImportedPlayer> = {};
const playersMap = new Map<string, ImportedPlayer>();
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);