mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
import { createServer, type Server } from 'node:http';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { getData, type CountryRecord } from 'country-list';
|
|
import { nodecg } from './util/nodecg.js';
|
|
|
|
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
|
|
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://start.gg/oauth/authorize';
|
|
const STARTGG_OAUTH_TOKEN_ENDPOINT = 'https://api.start.gg/oauth/token';
|
|
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
|
|
const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
|
|
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
|
|
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
|
|
|
const RECENT_TOURNAMENTS_LIMIT = 12;
|
|
const PARTICIPANTS_PAGE_SIZE = 120;
|
|
|
|
interface StartGGGraphQLResponse<T> {
|
|
data?: T;
|
|
errors?: Array<{ message?: string }>;
|
|
}
|
|
|
|
interface RecentTournament {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
startAt: number | null;
|
|
}
|
|
|
|
interface ImportedPlayer {
|
|
id: string;
|
|
gamertag: string;
|
|
name: string;
|
|
team: string;
|
|
country: string;
|
|
twitter: string;
|
|
}
|
|
|
|
interface OAuthConfig {
|
|
clientId: string;
|
|
clientSecret: string;
|
|
callbackPort: number;
|
|
}
|
|
|
|
interface OAuthSession {
|
|
sessionId: string;
|
|
state: string;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
status: 'pending' | 'completed' | 'error' | 'expired';
|
|
token?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface OAuthTokenResponse {
|
|
access_token?: string;
|
|
error?: string;
|
|
error_description?: string;
|
|
}
|
|
|
|
const oauthSessions = new Map<string, OAuthSession>();
|
|
let oauthCallbackServer: Server | null = null;
|
|
|
|
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
|
|
const response = await fetch(STARTGG_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ query, variables }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`start.gg responded with ${response.status}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as StartGGGraphQLResponse<T>;
|
|
if (payload.errors?.length) {
|
|
throw new Error(payload.errors[0]?.message || 'Unknown start.gg error');
|
|
}
|
|
|
|
if (!payload.data) {
|
|
throw new Error('No data returned by start.gg');
|
|
}
|
|
|
|
return payload.data;
|
|
};
|
|
|
|
const countryByCode = new Set(getData().map((country: CountryRecord) => country.code.toUpperCase()));
|
|
const countryByName = new Map(getData().map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
|
|
|
|
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
|
const raw = (country || '').trim();
|
|
if (!raw) {
|
|
return '';
|
|
}
|
|
|
|
const upper = raw.toUpperCase();
|
|
if (countryByCode.has(upper)) {
|
|
return upper;
|
|
}
|
|
|
|
return countryByName.get(raw.toLowerCase()) ?? '';
|
|
};
|
|
|
|
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
|
if (typeof ack !== 'function') {
|
|
return;
|
|
}
|
|
ack(error, response);
|
|
};
|
|
|
|
const getOAuthConfig = (): OAuthConfig | null => {
|
|
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
|
const clientId = String(bundleConfig.startggClientId || '').trim();
|
|
const clientSecret = String(bundleConfig.startggClientSecret || '').trim();
|
|
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
|
|
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
|
|
|
|
if (!clientId || !clientSecret) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
clientId,
|
|
clientSecret,
|
|
callbackPort,
|
|
};
|
|
};
|
|
|
|
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`;
|
|
|
|
const cleanupExpiredOAuthSessions = () => {
|
|
const now = Date.now();
|
|
oauthSessions.forEach((session) => {
|
|
if (session.expiresAt <= now && session.status === 'pending') {
|
|
oauthSessions.set(session.sessionId, {
|
|
...session,
|
|
status: 'expired',
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>${title}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
|
|
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
|
|
.ok { color: #66bb6a; }
|
|
.ko { color: #ef5350; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="box">
|
|
<h2>${title}</h2>
|
|
<p>${message}</p>
|
|
<p>Puedes cerrar esta pestaña y volver a Scoreko.</p>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
|
|
const exchangeOAuthCodeForToken = async (
|
|
code: string,
|
|
redirectUri: string,
|
|
oauthConfig: OAuthConfig,
|
|
): Promise<string> => {
|
|
const response = await fetch(STARTGG_OAUTH_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: oauthConfig.clientId,
|
|
client_secret: oauthConfig.clientSecret,
|
|
redirect_uri: redirectUri,
|
|
}),
|
|
});
|
|
|
|
const payload = (await response.json()) as OAuthTokenResponse;
|
|
if (!response.ok) {
|
|
throw new Error(payload.error_description || payload.error || `OAuth token request failed (${response.status})`);
|
|
}
|
|
|
|
const token = String(payload.access_token || '').trim();
|
|
if (!token) {
|
|
throw new Error('OAuth token response did not include an access token');
|
|
}
|
|
|
|
return token;
|
|
};
|
|
|
|
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
|
|
if (oauthCallbackServer) {
|
|
return;
|
|
}
|
|
|
|
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
|
|
|
|
const server = createServer((req, res) => {
|
|
if (!req.url) {
|
|
res.statusCode = 400;
|
|
res.end('Bad request');
|
|
return;
|
|
}
|
|
|
|
const requestUrl = new URL(req.url, callbackUrl);
|
|
if (requestUrl.pathname !== STARTGG_OAUTH_CALLBACK_PATH) {
|
|
res.statusCode = 404;
|
|
res.end('Not found');
|
|
return;
|
|
}
|
|
|
|
cleanupExpiredOAuthSessions();
|
|
|
|
const state = requestUrl.searchParams.get('state') || '';
|
|
const code = requestUrl.searchParams.get('code') || '';
|
|
const error = requestUrl.searchParams.get('error') || '';
|
|
|
|
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.'));
|
|
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.'));
|
|
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}`));
|
|
return;
|
|
}
|
|
|
|
if (!code) {
|
|
oauthSessions.set(session.sessionId, {
|
|
...session,
|
|
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.'));
|
|
return;
|
|
}
|
|
|
|
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
|
|
.then((token) => {
|
|
oauthSessions.set(session.sessionId, {
|
|
...session,
|
|
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,
|
|
});
|
|
});
|
|
|
|
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...'));
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(oauthConfig.callbackPort, '127.0.0.1', () => {
|
|
server.off('error', reject);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
oauthCallbackServer = server;
|
|
};
|
|
|
|
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).');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureOAuthCallbackServer(oauthConfig);
|
|
} catch (serverError) {
|
|
const message = serverError instanceof Error ? serverError.message : 'No se pudo iniciar el callback OAuth local';
|
|
sendAck(ack, message);
|
|
return;
|
|
}
|
|
|
|
cleanupExpiredOAuthSessions();
|
|
|
|
const sessionId = randomUUID();
|
|
const state = randomUUID();
|
|
const now = Date.now();
|
|
const session: OAuthSession = {
|
|
sessionId,
|
|
state,
|
|
createdAt: now,
|
|
expiresAt: now + STARTGG_OAUTH_SESSION_TTL_MS,
|
|
status: 'pending',
|
|
};
|
|
oauthSessions.set(sessionId, session);
|
|
|
|
const params = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: oauthConfig.clientId,
|
|
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
|
|
scope: STARTGG_OAUTH_SCOPES,
|
|
state,
|
|
});
|
|
|
|
sendAck(ack, null, {
|
|
sessionId,
|
|
authUrl: `${STARTGG_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
|
|
});
|
|
});
|
|
|
|
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()
|
|
: '';
|
|
|
|
if (!sessionId) {
|
|
sendAck(ack, 'Missing OAuth session id');
|
|
return;
|
|
}
|
|
|
|
const session = oauthSessions.get(sessionId);
|
|
if (!session) {
|
|
sendAck(ack, 'OAuth session not found');
|
|
return;
|
|
}
|
|
|
|
sendAck(ack, null, {
|
|
status: session.status,
|
|
token: session.status === 'completed' ? session.token : undefined,
|
|
error: session.status === 'error' ? session.error : undefined,
|
|
});
|
|
});
|
|
|
|
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()
|
|
: '';
|
|
|
|
if (!token) {
|
|
sendAck(ack, 'Missing start.gg API token');
|
|
return;
|
|
}
|
|
|
|
const query = `
|
|
query RecentTournaments($perPage: Int!) {
|
|
currentUser {
|
|
tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) {
|
|
nodes {
|
|
id
|
|
name
|
|
slug
|
|
startAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const data = await requestStartGG<{
|
|
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
|
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
|
|
|
const tournaments = data.currentUser?.tournaments.nodes
|
|
.filter((item) => item.slug)
|
|
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
|
.map((item) => ({
|
|
id: item.id,
|
|
name: item.name,
|
|
slug: item.slug,
|
|
startAt: item.startAt,
|
|
})) ?? [];
|
|
|
|
sendAck(ack, null, tournaments);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
|
|
sendAck(ack, message);
|
|
}
|
|
});
|
|
|
|
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();
|
|
|
|
if (!token) {
|
|
sendAck(ack, 'Missing start.gg API token');
|
|
return;
|
|
}
|
|
|
|
if (!slug) {
|
|
sendAck(ack, 'Missing tournament slug');
|
|
return;
|
|
}
|
|
|
|
const query = `
|
|
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
|
tournament(slug: $slug) {
|
|
participants(query: { page: $page, perPage: $perPage }) {
|
|
pageInfo {
|
|
totalPages
|
|
}
|
|
nodes {
|
|
id
|
|
gamerTag
|
|
prefix
|
|
user {
|
|
location {
|
|
country
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
let currentPage = 1;
|
|
let totalPages = 1;
|
|
const playersMap: Record<string, ImportedPlayer> = {};
|
|
|
|
while (currentPage <= totalPages) {
|
|
const data = await requestStartGG<{
|
|
tournament: {
|
|
participants: {
|
|
pageInfo: { totalPages: number };
|
|
nodes: Array<{
|
|
id: number;
|
|
gamerTag: string | null;
|
|
prefix: string | null;
|
|
user: {
|
|
location: {
|
|
country: string | null;
|
|
} | null;
|
|
} | null;
|
|
}>;
|
|
};
|
|
} | null;
|
|
}>(query, {
|
|
slug,
|
|
page: currentPage,
|
|
perPage: PARTICIPANTS_PAGE_SIZE,
|
|
}, token);
|
|
|
|
if (!data.tournament) {
|
|
throw new Error('Tournament not found');
|
|
}
|
|
|
|
totalPages = Math.max(data.tournament.participants.pageInfo.totalPages || 1, 1);
|
|
|
|
data.tournament.participants.nodes.forEach((participant) => {
|
|
const playerId = String(participant.id);
|
|
const gamertag = (participant.gamerTag || '').trim();
|
|
if (!gamertag) {
|
|
return;
|
|
}
|
|
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
|
|
playersMap[playerId] = {
|
|
id: playerId,
|
|
gamertag,
|
|
name: gamertag,
|
|
team: (participant.prefix || '').trim(),
|
|
country,
|
|
twitter: '',
|
|
};
|
|
});
|
|
|
|
currentPage += 1;
|
|
}
|
|
|
|
sendAck(ack, null, Object.values(playersMap));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
|
|
sendAck(ack, message);
|
|
}
|
|
});
|