refactor(startgg): simplify payload handling and oauth session updates

This commit is contained in:
Pandipipas
2026-02-17 18:12:48 +01:00
parent aac74e36f2
commit 23846acd99
+58 -62
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>
@@ -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, 'OAuth inválido', 'No se encontró una sesión activa para esta autorización.');
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, 'Sesión expirada', 'La sesión OAuth expiró. Vuelve a iniciar el proceso desde 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 cancelado', `start.gg devolvió el 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, 'OAuth incompleto', 'No se recibió un código de autorización.');
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, 'Autorización recibida', 'Se recibió tu autorización. Finalizando el login en segundo plano...');
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) => {
@@ -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);