Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27c0298ca2 | |||
| aea381ea35 | |||
| 0857472ad4 | |||
| 661cf1264a | |||
| b3fc84fde2 | |||
| 3de99ef810 |
@@ -7,12 +7,16 @@ import { useScoreboardStore } from '../stores/scoreboard';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const ALL_FIGHTING_GAME_OPTIONS = [
|
export const ALL_FIGHTING_GAME_OPTIONS = [
|
||||||
|
|
||||||
'2XKO',
|
'2XKO',
|
||||||
|
'FATAL FURY: City of the Wolves',
|
||||||
|
'Guilty Gear -Strive-',
|
||||||
|
'Invincible VS',
|
||||||
'Mortal Kombat 1',
|
'Mortal Kombat 1',
|
||||||
'Street Fighter 6',
|
'Street Fighter 6',
|
||||||
'TEKKEN 8',
|
'TEKKEN 8',
|
||||||
'Guilty Gear -Strive-',
|
|
||||||
'THE KING OF FIGHTERS XV',
|
'THE KING OF FIGHTERS XV',
|
||||||
|
|
||||||
].map((game) => ({ label: game, value: game }));
|
].map((game) => ({ label: game, value: game }));
|
||||||
|
|
||||||
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
|
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IntegrationTournament {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
startAt: number | null;
|
||||||
|
endAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationPlayer {
|
||||||
|
id: string;
|
||||||
|
gamertag: string;
|
||||||
|
name: string;
|
||||||
|
team: string;
|
||||||
|
country: string;
|
||||||
|
twitter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemporaryPlayerMeta {
|
||||||
|
expiresAt: number;
|
||||||
|
tournamentSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemporaryPlayersMap = Record<string, TemporaryPlayerMeta>;
|
||||||
|
|
||||||
|
interface TournamentOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthSessionResponse {
|
||||||
|
sessionId: string;
|
||||||
|
authUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthStatusResponse {
|
||||||
|
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||||
|
token?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayersStore {
|
||||||
|
upsertPlayer: (id: string, data: Omit<IntegrationPlayer, 'id'>) => void;
|
||||||
|
removePlayer: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseIntegrationOptions {
|
||||||
|
/** Prefijo de los mensajes NodeCG, p.ej. 'startgg' | 'challonge' */
|
||||||
|
messagePrefix: string;
|
||||||
|
/** Nombre legible del proveedor para mensajes de error */
|
||||||
|
providerLabel: string;
|
||||||
|
/** Clave de localStorage para el token */
|
||||||
|
tokenStorageKey: string;
|
||||||
|
/** Clave de localStorage para los jugadores temporales */
|
||||||
|
tempPlayersStorageKey: string;
|
||||||
|
/** Segundos que duran los jugadores temporales si el torneo no tiene endAt */
|
||||||
|
tempFallbackDurationSeconds: number;
|
||||||
|
/** Mensaje de error personalizado cuando la API devuelve 401 */
|
||||||
|
on401Message?: string;
|
||||||
|
/** Store de jugadores */
|
||||||
|
playersStore: PlayersStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response as T);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Composable ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useIntegration(options: UseIntegrationOptions) {
|
||||||
|
const {
|
||||||
|
messagePrefix,
|
||||||
|
providerLabel,
|
||||||
|
tokenStorageKey,
|
||||||
|
tempPlayersStorageKey,
|
||||||
|
tempFallbackDurationSeconds,
|
||||||
|
on401Message,
|
||||||
|
playersStore,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// ── Token ───────────────────────────────────────────────────────────────────
|
||||||
|
const token = ref(localStorage.getItem(tokenStorageKey) ?? '');
|
||||||
|
const hasValidatedToken = ref(false);
|
||||||
|
|
||||||
|
watch(token, (value) => {
|
||||||
|
localStorage.setItem(tokenStorageKey, value);
|
||||||
|
hasValidatedToken.value = false;
|
||||||
|
if (!value.trim()) {
|
||||||
|
recentTournaments.value = [];
|
||||||
|
selectedTournamentSlug.value = '';
|
||||||
|
tournamentInput.value = '';
|
||||||
|
tournamentsError.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lista de torneos ────────────────────────────────────────────────────────
|
||||||
|
const recentTournaments = ref<IntegrationTournament[]>([]);
|
||||||
|
const loadingTournaments = ref(false);
|
||||||
|
const tournamentsError = ref('');
|
||||||
|
const selectedTournamentSlug = ref('');
|
||||||
|
const tournamentInput = ref('');
|
||||||
|
|
||||||
|
const tournamentOptions = computed<TournamentOption[]>(() =>
|
||||||
|
recentTournaments.value.map((t) => ({
|
||||||
|
label: t.name,
|
||||||
|
value: t.slug,
|
||||||
|
caption: t.slug,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTournamentOptions = ref<TournamentOption[]>(tournamentOptions.value);
|
||||||
|
|
||||||
|
watch(tournamentOptions, (value) => {
|
||||||
|
filteredTournamentOptions.value = value;
|
||||||
|
if (
|
||||||
|
selectedTournamentSlug.value &&
|
||||||
|
!recentTournaments.value.some((t) => t.slug === selectedTournamentSlug.value)
|
||||||
|
) {
|
||||||
|
selectedTournamentSlug.value = '';
|
||||||
|
tournamentInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterTournaments = (value: string, update: (cb: () => void) => void) => {
|
||||||
|
update(() => {
|
||||||
|
const needle = value.toLowerCase().trim();
|
||||||
|
filteredTournamentOptions.value = needle
|
||||||
|
? tournamentOptions.value.filter(
|
||||||
|
(o) =>
|
||||||
|
o.label.toLowerCase().includes(needle) ||
|
||||||
|
o.caption.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
: tournamentOptions.value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedTournamentOption = computed<IntegrationTournament | null>(
|
||||||
|
() => recentTournaments.value.find((t) => t.slug === selectedTournamentSlug.value) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canImportSelectedTournament = computed(() => Boolean(selectedTournamentOption.value));
|
||||||
|
const hasTokenConfigured = computed(() => Boolean(token.value.trim()));
|
||||||
|
|
||||||
|
const loadRecentTournaments = async () => {
|
||||||
|
const currentToken = token.value.trim();
|
||||||
|
if (!currentToken) {
|
||||||
|
tournamentsError.value = `Add your ${providerLabel} token to load tournaments.`;
|
||||||
|
recentTournaments.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tournamentsError.value = '';
|
||||||
|
loadingTournaments.value = true;
|
||||||
|
try {
|
||||||
|
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>(
|
||||||
|
`${messagePrefix}:fetchRecentTournaments`,
|
||||||
|
{ token: currentToken },
|
||||||
|
);
|
||||||
|
hasValidatedToken.value = true;
|
||||||
|
recentTournaments.value = tournaments;
|
||||||
|
if (!tournaments.length) {
|
||||||
|
tournamentsError.value = 'There are no recent tournaments for this account.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
hasValidatedToken.value = false;
|
||||||
|
const message = error instanceof Error ? error.message : 'Could not load tournaments.';
|
||||||
|
tournamentsError.value =
|
||||||
|
on401Message && message.includes('401') ? on401Message : message;
|
||||||
|
recentTournaments.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingTournaments.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Importación de jugadores ────────────────────────────────────────────────
|
||||||
|
const players = ref<IntegrationPlayer[]>([]);
|
||||||
|
const selectedPlayerIds = ref<string[]>([]);
|
||||||
|
const importDialogOpen = ref(false);
|
||||||
|
const importDialogError = ref('');
|
||||||
|
const loadingPlayers = ref(false);
|
||||||
|
const importingTournament = ref<IntegrationTournament | null>(null);
|
||||||
|
|
||||||
|
const openImportDialog = async (tournament: IntegrationTournament): Promise<void> => {
|
||||||
|
importingTournament.value = tournament;
|
||||||
|
importDialogOpen.value = true;
|
||||||
|
importDialogError.value = '';
|
||||||
|
loadingPlayers.value = true;
|
||||||
|
selectedPlayerIds.value = [];
|
||||||
|
selectedTournamentSlug.value = tournament.slug;
|
||||||
|
tournamentInput.value = tournament.name;
|
||||||
|
players.value = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>(
|
||||||
|
`${messagePrefix}:fetchTournamentPlayers`,
|
||||||
|
{ token: token.value.trim(), slug: tournament.slug },
|
||||||
|
);
|
||||||
|
players.value = importedPlayers;
|
||||||
|
selectedPlayerIds.value = importedPlayers.map((p) => p.id);
|
||||||
|
} catch (error) {
|
||||||
|
importDialogError.value =
|
||||||
|
error instanceof Error ? error.message : 'Could not load players';
|
||||||
|
importDialogOpen.value = false;
|
||||||
|
} finally {
|
||||||
|
loadingPlayers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSelectedTournamentImportDialog = () => {
|
||||||
|
if (selectedTournamentOption.value) {
|
||||||
|
void openImportDialog(selectedTournamentOption.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllPlayers = () => {
|
||||||
|
selectedPlayerIds.value =
|
||||||
|
selectedPlayerIds.value.length === players.value.length
|
||||||
|
? []
|
||||||
|
: players.value.map((p) => p.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importSelectedPlayers = () => {
|
||||||
|
const selected = players.value.filter((p) => selectedPlayerIds.value.includes(p.id));
|
||||||
|
const tournament = importingTournament.value;
|
||||||
|
const fallbackEndAt =
|
||||||
|
(tournament?.startAt ?? Math.floor(Date.now() / 1000)) + tempFallbackDurationSeconds;
|
||||||
|
const expiresAt = tournament?.endAt ?? fallbackEndAt;
|
||||||
|
const nextMeta = { ...temporaryPlayers.value };
|
||||||
|
|
||||||
|
for (const player of selected) {
|
||||||
|
playersStore.upsertPlayer(player.id, {
|
||||||
|
gamertag: player.gamertag,
|
||||||
|
name: player.name,
|
||||||
|
team: player.team,
|
||||||
|
country: player.country,
|
||||||
|
twitter: player.twitter,
|
||||||
|
});
|
||||||
|
if (tournament) {
|
||||||
|
nextMeta[player.id] = { expiresAt, tournamentSlug: tournament.slug };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
temporaryPlayers.value = nextMeta;
|
||||||
|
persistTemporaryPlayers();
|
||||||
|
importDialogOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Jugadores temporales ────────────────────────────────────────────────────
|
||||||
|
const loadTemporaryPlayers = (): TemporaryPlayersMap => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(tempPlayersStorageKey);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||||
|
|
||||||
|
const result: TemporaryPlayersMap = {};
|
||||||
|
Object.entries(parsed as Record<string, unknown>).forEach(([playerId, value]) => {
|
||||||
|
if (!playerId || typeof value !== 'object' || value === null) return;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
const expiresAt = Number(candidate.expiresAt);
|
||||||
|
const tournamentSlug = String(candidate.tournamentSlug ?? '').trim();
|
||||||
|
if (!Number.isFinite(expiresAt) || expiresAt <= 0 || !tournamentSlug) return;
|
||||||
|
result[playerId] = { expiresAt, tournamentSlug };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const temporaryPlayers = ref<TemporaryPlayersMap>({});
|
||||||
|
|
||||||
|
const persistTemporaryPlayers = () => {
|
||||||
|
localStorage.setItem(tempPlayersStorageKey, JSON.stringify(temporaryPlayers.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina del store y del mapa los jugadores temporales cuyo expiresAt
|
||||||
|
* ha pasado. Se llama periódicamente en onMounted.
|
||||||
|
*/
|
||||||
|
const cleanupExpiredTemporaryPlayers = () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiredIds = Object.entries(temporaryPlayers.value)
|
||||||
|
.filter(([, meta]) => meta.expiresAt <= now)
|
||||||
|
.map(([id]) => id);
|
||||||
|
|
||||||
|
if (!expiredIds.length) return;
|
||||||
|
|
||||||
|
const nextMeta = { ...temporaryPlayers.value };
|
||||||
|
for (const id of expiredIds) {
|
||||||
|
playersStore.removePlayer(id);
|
||||||
|
delete nextMeta[id];
|
||||||
|
}
|
||||||
|
temporaryPlayers.value = nextMeta;
|
||||||
|
persistTemporaryPlayers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── OAuth ───────────────────────────────────────────────────────────────────
|
||||||
|
const oauthLoading = ref(false);
|
||||||
|
const oauthSessionId = ref('');
|
||||||
|
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (oauthPollingTimer) {
|
||||||
|
clearInterval(oauthPollingTimer);
|
||||||
|
oauthPollingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOAuthStatus = async () => {
|
||||||
|
if (!oauthSessionId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await sendNodeCGMessage<OAuthStatusResponse>(
|
||||||
|
`${messagePrefix}:getOAuthSessionStatus`,
|
||||||
|
{ sessionId: oauthSessionId.value },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status.status === 'completed' && status.token) {
|
||||||
|
token.value = status.token;
|
||||||
|
oauthLoading.value = false;
|
||||||
|
stopPolling();
|
||||||
|
oauthSessionId.value = '';
|
||||||
|
tournamentsError.value = '';
|
||||||
|
await loadRecentTournaments();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'error' || status.status === 'expired') {
|
||||||
|
oauthLoading.value = false;
|
||||||
|
stopPolling();
|
||||||
|
oauthSessionId.value = '';
|
||||||
|
tournamentsError.value =
|
||||||
|
status.error ?? `Could not complete OAuth login with ${providerLabel}.`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
oauthLoading.value = false;
|
||||||
|
stopPolling();
|
||||||
|
oauthSessionId.value = '';
|
||||||
|
tournamentsError.value =
|
||||||
|
error instanceof Error ? error.message : 'Could not verify OAuth status.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectWithOAuth = async () => {
|
||||||
|
oauthLoading.value = true;
|
||||||
|
tournamentsError.value = '';
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await sendNodeCGMessage<OAuthSessionResponse>(
|
||||||
|
`${messagePrefix}:createOAuthSession`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
oauthSessionId.value = session.sessionId;
|
||||||
|
window.open(session.authUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
|
||||||
|
oauthPollingTimer = setInterval(() => {
|
||||||
|
void checkOAuthStatus();
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
oauthLoading.value = false;
|
||||||
|
tournamentsError.value =
|
||||||
|
error instanceof Error ? error.message : `Could not start OAuth with ${providerLabel}.`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Ciclo de vida ───────────────────────────────────────────────────────────
|
||||||
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
temporaryPlayers.value = loadTemporaryPlayers();
|
||||||
|
cleanupExpiredTemporaryPlayers();
|
||||||
|
cleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000);
|
||||||
|
|
||||||
|
if (token.value.trim()) {
|
||||||
|
void loadRecentTournaments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopPolling();
|
||||||
|
if (cleanupTimer) {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
cleanupTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Retorno como reactive para auto-unwrap en templates ─────────────────────
|
||||||
|
return reactive({
|
||||||
|
// Token
|
||||||
|
token,
|
||||||
|
hasTokenConfigured,
|
||||||
|
hasValidatedToken,
|
||||||
|
|
||||||
|
// Torneos
|
||||||
|
recentTournaments,
|
||||||
|
loadingTournaments,
|
||||||
|
tournamentsError,
|
||||||
|
selectedTournamentSlug,
|
||||||
|
tournamentInput,
|
||||||
|
tournamentOptions,
|
||||||
|
filteredTournamentOptions,
|
||||||
|
selectedTournamentOption,
|
||||||
|
canImportSelectedTournament,
|
||||||
|
filterTournaments,
|
||||||
|
loadRecentTournaments,
|
||||||
|
|
||||||
|
// Importación
|
||||||
|
players,
|
||||||
|
selectedPlayerIds,
|
||||||
|
importDialogOpen,
|
||||||
|
importDialogError,
|
||||||
|
loadingPlayers,
|
||||||
|
importingTournament,
|
||||||
|
openImportDialog,
|
||||||
|
openSelectedTournamentImportDialog,
|
||||||
|
importSelectedPlayers,
|
||||||
|
toggleAllPlayers,
|
||||||
|
|
||||||
|
// Jugadores temporales
|
||||||
|
temporaryPlayers,
|
||||||
|
|
||||||
|
// OAuth
|
||||||
|
oauthLoading,
|
||||||
|
connectWithOAuth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationHandle = ReturnType<typeof useIntegration>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { nodecg } from './util/nodecg.js';
|
import { nodecg } from './util/nodecg.js';
|
||||||
|
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||||
|
|
||||||
|
// ─── Constantes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1';
|
const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1';
|
||||||
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
|
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
|
||||||
@@ -17,21 +18,9 @@ const CHALLONGE_OAUTH_SCOPES = [
|
|||||||
const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
|
const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
|
||||||
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
|
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
|
||||||
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const RECENT_TOURNAMENTS_LIMIT = 20;
|
||||||
|
|
||||||
interface OAuthConfig {
|
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
callbackPort: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OAuthSession {
|
|
||||||
sessionId: string;
|
|
||||||
state: string;
|
|
||||||
expiresAt: number;
|
|
||||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
|
||||||
token?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OAuthTokenResponse {
|
interface OAuthTokenResponse {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
@@ -57,157 +46,90 @@ interface ImportedPlayer {
|
|||||||
twitter: string;
|
twitter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthSessions = new Map<string, OAuthSession>();
|
// ─── Config OAuth ──────────────────────────────────────────────────────────────
|
||||||
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 getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
|
||||||
for (const key of keys) {
|
|
||||||
const raw = payload[key];
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
|
||||||
if (typeof ack !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ack(error, response);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOAuthConfig = (): OAuthConfig | null => {
|
const getOAuthConfig = (): OAuthConfig | null => {
|
||||||
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
||||||
const clientId = String(bundleConfig.challongeClientId || '').trim();
|
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
|
||||||
const clientSecret = String(bundleConfig.challongeClientSecret || '').trim();
|
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
|
||||||
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
|
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
|
||||||
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
const callbackPort =
|
||||||
|
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
if (!clientId || !clientSecret) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return { clientId, clientSecret, callbackPort };
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
callbackPort,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${CHALLONGE_OAUTH_CALLBACK_PATH}`;
|
// ─── Intercambio de token ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
|
|
||||||
const session = oauthSessions.get(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthSessions.set(sessionId, {
|
|
||||||
...session,
|
|
||||||
...update,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanupExpiredOAuthSessions = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
oauthSessions.forEach((session, sessionId) => {
|
|
||||||
if (session.expiresAt <= now && session.status === 'pending') {
|
|
||||||
updateOAuthSession(sessionId, { 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; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="box">
|
|
||||||
<h2>${title}</h2>
|
|
||||||
<p>${message}</p>
|
|
||||||
<p>You can close this tab and return to Scoreko.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
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 parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
|
||||||
const rawBody = await response.text();
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
|
||||||
} catch {
|
|
||||||
return { message: rawBody };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exchangeOAuthCodeForToken = async (
|
const exchangeOAuthCodeForToken = async (
|
||||||
code: string,
|
code: string,
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
oauthConfig: OAuthConfig,
|
config: OAuthConfig,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
client_id: oauthConfig.clientId,
|
client_id: config.clientId,
|
||||||
client_secret: oauthConfig.clientSecret,
|
client_secret: config.clientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
|
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
body: params.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await parseOAuthTokenPayload(response);
|
const rawBody = await response.text();
|
||||||
|
let payload: OAuthTokenResponse;
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`);
|
payload = JSON.parse(rawBody) as OAuthTokenResponse;
|
||||||
|
} catch {
|
||||||
|
payload = { message: rawBody };
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = String(payload.access_token || '').trim();
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
payload.error_description ??
|
||||||
|
payload.error ??
|
||||||
|
payload.message ??
|
||||||
|
`OAuth token request failed (${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(payload.access_token ?? '').trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token');
|
throw new Error(
|
||||||
|
payload.error_description ??
|
||||||
|
payload.error ??
|
||||||
|
payload.message ??
|
||||||
|
'OAuth token response did not include an access token',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const oauthServer = createOAuthServer({
|
||||||
|
provider: 'Challonge',
|
||||||
|
callbackPath: CHALLONGE_OAUTH_CALLBACK_PATH,
|
||||||
|
authorizeEndpoint: CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT,
|
||||||
|
scope: CHALLONGE_OAUTH_SCOPES,
|
||||||
|
sessionTtlMs: CHALLONGE_OAUTH_SESSION_TTL_MS,
|
||||||
|
exchangeToken: exchangeOAuthCodeForToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── API de Challonge ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null;
|
||||||
|
|
||||||
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
||||||
const rawBody = await response.text();
|
const rawBody = await response.text();
|
||||||
if (!rawBody) {
|
if (!rawBody) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(rawBody) as unknown;
|
return JSON.parse(rawBody) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -215,9 +137,21 @@ const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una petición autenticada a la API de Challonge.
|
||||||
|
*
|
||||||
|
* Intenta primero con OAuth v2 (Bearer token).
|
||||||
|
* Si recibe 401, reintenta con autenticación v1 (API key personal pegada manualmente).
|
||||||
|
* En cualquier otro error no-2xx, lanza inmediatamente.
|
||||||
|
*
|
||||||
|
* CORRECCIÓN: en la versión anterior, el bloque de error final era dead code
|
||||||
|
* porque el body de v2 ya había sido consumido y la condición `!v2Response.ok`
|
||||||
|
* nunca se alcanzaba tras el fallback v1.
|
||||||
|
*/
|
||||||
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
||||||
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
||||||
|
|
||||||
|
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
|
||||||
const v2Response = await fetch(requestUrl, {
|
const v2Response = await fetch(requestUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -226,14 +160,13 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const v2Payload = await parseJsonResponse(v2Response);
|
const v2Payload = await parseJsonResponse(v2Response);
|
||||||
|
|
||||||
if (v2Response.ok) {
|
if (v2Response.ok) {
|
||||||
return v2Payload;
|
return v2Payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for personal API keys pasted manually (v1 auth style).
|
// ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
|
||||||
if (v2Response.status === 401) {
|
if (v2Response.status === 401) {
|
||||||
const v1Response = await fetch(requestUrl, {
|
const v1Response = await fetch(requestUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -243,46 +176,68 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
|
|||||||
Authorization: token,
|
Authorization: token,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const v1Payload = await parseJsonResponse(v1Response);
|
const v1Payload = await parseJsonResponse(v1Response);
|
||||||
|
|
||||||
if (v1Response.ok) {
|
if (v1Response.ok) {
|
||||||
return v1Payload;
|
return v1Payload;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const maybeError = v2Payload as { errors?: { detail?: string }; error?: string } | null;
|
const v1Error = v1Payload as ChallongeErrorPayload;
|
||||||
if (!v2Response.ok) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
v1Error?.errors?.detail ??
|
||||||
|
v1Error?.error ??
|
||||||
|
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return v2Payload;
|
// ── Otros errores v2 (4xx/5xx que no sean 401) ────────────────────────────
|
||||||
|
const v2Error = v2Payload as ChallongeErrorPayload;
|
||||||
|
throw new Error(
|
||||||
|
v2Error?.errors?.detail ??
|
||||||
|
v2Error?.error ??
|
||||||
|
`Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Parsers de respuesta ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const normalizeTournamentSlug = (value: string): string => {
|
const normalizeTournamentSlug = (value: string): string => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) return '';
|
||||||
return '';
|
return trimmed
|
||||||
|
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||||
|
.replace(/^tournaments\//i, '')
|
||||||
|
.replace(/^\/+/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = payload[key];
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return trimmed.replace(/^https?:\/\/[^/]+\//i, '').replace(/^tournaments\//i, '').replace(/^\/+/, '');
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
||||||
const rows: RecentTournament[] = [];
|
const rows: RecentTournament[] = [];
|
||||||
|
|
||||||
const push = (candidate: Record<string, unknown>) => {
|
const push = (candidate: Record<string, unknown>) => {
|
||||||
const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
|
const attributes =
|
||||||
? (candidate.attributes as Record<string, unknown>)
|
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||||
: candidate;
|
? (candidate.attributes as Record<string, unknown>)
|
||||||
|
: candidate;
|
||||||
|
|
||||||
const id = String(candidate.id || attributes.id || attributes.tournament_id || '').trim();
|
const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim();
|
||||||
const name = String(attributes.name || attributes.full_name || '').trim();
|
const name = String(attributes.name ?? attributes.full_name ?? '').trim();
|
||||||
const slug = normalizeTournamentSlug(String(attributes.url || attributes.slug || attributes.identifier || id));
|
const slug = normalizeTournamentSlug(
|
||||||
|
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
|
||||||
|
);
|
||||||
|
|
||||||
if (!id || !name || !slug) {
|
if (!id || !name || !slug) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
id,
|
id,
|
||||||
@@ -294,26 +249,25 @@ const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
payload.forEach((row) => {
|
for (const row of payload) {
|
||||||
const wrapper = row as Record<string, unknown>;
|
const wrapper = row as Record<string, unknown>;
|
||||||
const tournament = (typeof wrapper.tournament === 'object' && wrapper.tournament !== null)
|
const tournament =
|
||||||
? (wrapper.tournament as Record<string, unknown>)
|
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
|
||||||
: wrapper;
|
? (wrapper.tournament as Record<string, unknown>)
|
||||||
|
: wrapper;
|
||||||
push(tournament);
|
push(tournament);
|
||||||
});
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload === 'object' && payload !== null) {
|
if (typeof payload === 'object' && payload !== null) {
|
||||||
const root = payload as Record<string, unknown>;
|
const data = (payload as Record<string, unknown>).data;
|
||||||
const data = root.data;
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
data.forEach((row) => {
|
for (const row of data) {
|
||||||
if (typeof row === 'object' && row !== null) {
|
if (typeof row === 'object' && row !== null) {
|
||||||
push(row as Record<string, unknown>);
|
push(row as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,201 +278,127 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
|
|||||||
const map = new Map<string, ImportedPlayer>();
|
const map = new Map<string, ImportedPlayer>();
|
||||||
|
|
||||||
const push = (candidate: Record<string, unknown>) => {
|
const push = (candidate: Record<string, unknown>) => {
|
||||||
const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
|
const attributes =
|
||||||
? (candidate.attributes as Record<string, unknown>)
|
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||||
: candidate;
|
? (candidate.attributes as Record<string, unknown>)
|
||||||
|
: candidate;
|
||||||
|
|
||||||
const id = String(candidate.id || attributes.id || attributes.participant_id || '').trim();
|
const id = String(
|
||||||
const gamertag = String(
|
candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
|
||||||
attributes.display_name
|
|
||||||
|| attributes.name
|
|
||||||
|| attributes.username
|
|
||||||
|| attributes.gamer_tag
|
|
||||||
|| '',
|
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
if (!id || !gamertag) {
|
const rawDisplayName = String(
|
||||||
return;
|
attributes.display_name ??
|
||||||
}
|
attributes.name ??
|
||||||
|
attributes.username ??
|
||||||
|
attributes.gamer_tag ??
|
||||||
|
'',
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (!id || !rawDisplayName) return;
|
||||||
|
|
||||||
|
// Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games).
|
||||||
|
// Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag.
|
||||||
|
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
|
||||||
|
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
|
||||||
|
|
||||||
|
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
|
||||||
|
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
|
||||||
|
|
||||||
|
// team_name de la API tiene prioridad; si no existe, usar el extraído del nombre.
|
||||||
|
const team = String(attributes.team_name ?? '').trim() || teamFromName;
|
||||||
|
|
||||||
|
// Challonge no expone un campo de nombre real separado del username/display_name.
|
||||||
|
// Se deja vacío para no duplicar el gamertag en el campo name.
|
||||||
map.set(id, {
|
map.set(id, {
|
||||||
id,
|
id,
|
||||||
gamertag,
|
gamertag,
|
||||||
name: gamertag,
|
name: '',
|
||||||
team: String(attributes.group_player_ids || attributes.team_name || '').trim(),
|
team,
|
||||||
country: '',
|
country: '',
|
||||||
twitter: String(attributes.twitter_handle || attributes.twitter || '').trim(),
|
twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
payload.forEach((row) => {
|
for (const row of payload) {
|
||||||
const wrapper = row as Record<string, unknown>;
|
const wrapper = row as Record<string, unknown>;
|
||||||
const participant = (typeof wrapper.participant === 'object' && wrapper.participant !== null)
|
const participant =
|
||||||
? (wrapper.participant as Record<string, unknown>)
|
typeof wrapper.participant === 'object' && wrapper.participant !== null
|
||||||
: wrapper;
|
? (wrapper.participant as Record<string, unknown>)
|
||||||
|
: wrapper;
|
||||||
push(participant);
|
push(participant);
|
||||||
});
|
}
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload === 'object' && payload !== null) {
|
if (typeof payload === 'object' && payload !== null) {
|
||||||
const root = payload as Record<string, unknown>;
|
const data = (payload as Record<string, unknown>).data;
|
||||||
const data = root.data;
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
data.forEach((row) => {
|
for (const row of data) {
|
||||||
if (typeof row === 'object' && row !== null) {
|
if (typeof row === 'object' && row !== null) {
|
||||||
push(row as Record<string, unknown>);
|
push(row as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
|
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
||||||
if (oauthCallbackServer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
|
const getStringProp = (payload: unknown, key: string): string => {
|
||||||
|
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
||||||
const server = createServer((req, res) => {
|
const value = (payload as Record<string, unknown>)[key];
|
||||||
if (!req.url) {
|
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
res.statusCode = 400;
|
|
||||||
res.end('Bad request');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestUrl = new URL(req.url, callbackUrl);
|
|
||||||
if (requestUrl.pathname !== CHALLONGE_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) {
|
|
||||||
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.expiresAt <= Date.now()) {
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'expired' });
|
|
||||||
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'error', error });
|
|
||||||
respondWithCallbackHtml(res, 400, 'OAuth canceled', `Challonge returned this error: ${error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
updateOAuthSession(session.sessionId, {
|
|
||||||
status: 'error',
|
|
||||||
error: 'Missing authorization code',
|
|
||||||
});
|
|
||||||
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
|
|
||||||
.then((token) => {
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
|
|
||||||
})
|
|
||||||
.catch((exchangeError) => {
|
|
||||||
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'error', error: message });
|
|
||||||
});
|
|
||||||
|
|
||||||
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||||
|
if (typeof ack === 'function') ack(error, response);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
||||||
const oauthConfig = getOAuthConfig();
|
const config = getOAuthConfig();
|
||||||
if (!oauthConfig) {
|
if (!config) {
|
||||||
sendAck(ack, 'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.');
|
sendAck(
|
||||||
|
ack,
|
||||||
|
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureOAuthCallbackServer(oauthConfig);
|
await oauthServer.ensureServer(config);
|
||||||
} catch (serverError) {
|
} catch (err) {
|
||||||
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
|
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
|
||||||
sendAck(ack, message);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupExpiredOAuthSessions();
|
const session = oauthServer.createSession(config);
|
||||||
|
sendAck(ack, null, session);
|
||||||
const sessionId = randomUUID();
|
|
||||||
const state = randomUUID();
|
|
||||||
oauthSessions.set(sessionId, {
|
|
||||||
sessionId,
|
|
||||||
state,
|
|
||||||
expiresAt: Date.now() + CHALLONGE_OAUTH_SESSION_TTL_MS,
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
response_type: 'code',
|
|
||||||
client_id: oauthConfig.clientId,
|
|
||||||
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
|
|
||||||
scope: CHALLONGE_OAUTH_SCOPES,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
sendAck(ack, null, {
|
|
||||||
sessionId,
|
|
||||||
authUrl: `${CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||||
cleanupExpiredOAuthSessions();
|
|
||||||
|
|
||||||
const sessionId = getStringProp(payload, 'sessionId');
|
const sessionId = getStringProp(payload, 'sessionId');
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
sendAck(ack, 'Missing OAuth session id');
|
sendAck(ack, 'Missing OAuth session id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = oauthSessions.get(sessionId);
|
const status = oauthServer.getSessionStatus(sessionId);
|
||||||
if (!session) {
|
if (!status) {
|
||||||
sendAck(ack, 'OAuth session not found');
|
sendAck(ack, 'OAuth session not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAck(ack, null, {
|
sendAck(ack, null, status);
|
||||||
status: session.status,
|
|
||||||
token: session.status === 'completed' ? session.token : undefined,
|
|
||||||
error: session.status === 'error' ? session.error : undefined,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||||
const token = getStringProp(payload, 'token');
|
const token = getStringProp(payload, 'token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
sendAck(ack, 'Missing Challonge API token');
|
sendAck(ack, 'Missing Challonge API token');
|
||||||
return;
|
return;
|
||||||
@@ -528,12 +408,10 @@ nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ac
|
|||||||
const raw = await requestChallonge('/tournaments.json', token);
|
const raw = await requestChallonge('/tournaments.json', token);
|
||||||
const tournaments = parseRecentTournaments(raw)
|
const tournaments = parseRecentTournaments(raw)
|
||||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||||
.slice(0, 20);
|
.slice(0, RECENT_TOURNAMENTS_LIMIT);
|
||||||
|
|
||||||
sendAck(ack, null, tournaments);
|
sendAck(ack, null, tournaments);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -541,22 +419,16 @@ nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ac
|
|||||||
const token = getStringProp(payload, 'token');
|
const token = getStringProp(payload, 'token');
|
||||||
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
||||||
|
|
||||||
if (!token) {
|
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
|
||||||
sendAck(ack, 'Missing Challonge API token');
|
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
sendAck(ack, 'Missing tournament slug');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await requestChallonge(`/tournaments/${encodeURIComponent(slug)}/participants.json`, token);
|
const raw = await requestChallonge(
|
||||||
const players = parseImportedPlayers(raw);
|
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
|
||||||
sendAck(ack, null, players);
|
token,
|
||||||
|
);
|
||||||
|
sendAck(ack, null, parseImportedPlayers(raw));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { getData, type CountryRecord } from 'country-list';
|
import { getData, type CountryRecord } from 'country-list';
|
||||||
|
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||||
import { nodecg } from './util/nodecg.js';
|
import { nodecg } from './util/nodecg.js';
|
||||||
|
|
||||||
|
// ─── Constantes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
|
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
|
||||||
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
|
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
|
||||||
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
|
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
|
||||||
@@ -17,6 +18,8 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
|||||||
const RECENT_TOURNAMENTS_LIMIT = 12;
|
const RECENT_TOURNAMENTS_LIMIT = 12;
|
||||||
const PARTICIPANTS_PAGE_SIZE = 120;
|
const PARTICIPANTS_PAGE_SIZE = 120;
|
||||||
|
|
||||||
|
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface StartGGGraphQLResponse<T> {
|
interface StartGGGraphQLResponse<T> {
|
||||||
data?: T;
|
data?: T;
|
||||||
errors?: Array<{ message?: string }>;
|
errors?: Array<{ message?: string }>;
|
||||||
@@ -39,21 +42,6 @@ interface ImportedPlayer {
|
|||||||
twitter: string;
|
twitter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OAuthConfig {
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
callbackPort: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OAuthSession {
|
|
||||||
sessionId: string;
|
|
||||||
state: string;
|
|
||||||
expiresAt: number;
|
|
||||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
|
||||||
token?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OAuthTokenResponse {
|
interface OAuthTokenResponse {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -61,31 +49,98 @@ interface OAuthTokenResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthSessions = new Map<string, OAuthSession>();
|
// ─── Config OAuth ──────────────────────────────────────────────────────────────
|
||||||
let oauthCallbackServer: Server | null = null;
|
|
||||||
|
|
||||||
const getStringProp = (payload: unknown, key: string): string => {
|
const getOAuthConfig = (): OAuthConfig | null => {
|
||||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) {
|
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
||||||
return '';
|
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;
|
||||||
|
|
||||||
const value = (payload as Record<string, unknown>)[key];
|
if (!clientId || !clientSecret) return null;
|
||||||
return typeof value === 'string' ? value.trim() : String(value || '').trim();
|
|
||||||
|
return { clientId, clientSecret, callbackPort };
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
|
// ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
|
||||||
const session = oauthSessions.get(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthSessions.set(sessionId, {
|
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||||
...session,
|
const rawBody = await response.text();
|
||||||
...update,
|
try {
|
||||||
|
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||||
|
} catch {
|
||||||
|
return { message: rawBody };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeOAuthCodeForToken = async (
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
config: OAuthConfig,
|
||||||
|
): Promise<string> => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let lastError = 'Unknown OAuth token exchange error';
|
||||||
|
|
||||||
|
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await parseOAuthTokenPayload(response);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const token = String(payload.access_token ?? '').trim();
|
||||||
|
if (token) return token;
|
||||||
|
lastError =
|
||||||
|
payload.error_description ??
|
||||||
|
payload.error ??
|
||||||
|
payload.message ??
|
||||||
|
'OAuth token response did not include an access token';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError =
|
||||||
|
payload.error_description ??
|
||||||
|
payload.error ??
|
||||||
|
payload.message ??
|
||||||
|
`OAuth token request failed (${response.status})`;
|
||||||
|
|
||||||
|
// Solo 404 justifica probar el siguiente endpoint
|
||||||
|
if (response.status !== 404) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(lastError);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
|
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const oauthServer = createOAuthServer({
|
||||||
|
provider: 'start.gg',
|
||||||
|
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
|
||||||
|
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
|
||||||
|
scope: STARTGG_OAUTH_SCOPES,
|
||||||
|
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
|
||||||
|
exchangeToken: exchangeOAuthCodeForToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GraphQL ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -107,7 +162,7 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
|
|||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload.data) {
|
if (!payload.data) {
|
||||||
@@ -117,286 +172,75 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
|
|||||||
return payload.data;
|
return payload.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Resolución de países ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const countries = getData();
|
const countries = getData();
|
||||||
const countryByCode = new Set(countries.map((country: CountryRecord) => country.code.toUpperCase()));
|
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
||||||
const countryByName = new Map(countries.map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
|
const countryByName = new Map(
|
||||||
|
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
|
||||||
|
);
|
||||||
|
|
||||||
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
||||||
const raw = (country || '').trim();
|
const raw = (country ?? '').trim();
|
||||||
if (!raw) {
|
if (!raw) return '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const upper = raw.toUpperCase();
|
const upper = raw.toUpperCase();
|
||||||
if (countryByCode.has(upper)) {
|
if (countryByCode.has(upper)) return upper;
|
||||||
return upper;
|
|
||||||
}
|
|
||||||
|
|
||||||
return countryByName.get(raw.toLowerCase()) ?? '';
|
return countryByName.get(raw.toLowerCase()) ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||||
if (typeof ack !== 'function') {
|
if (typeof ack === 'function') ack(error, response);
|
||||||
return;
|
|
||||||
}
|
|
||||||
ack(error, response);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOAuthConfig = (): OAuthConfig | null => {
|
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||||
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, sessionId) => {
|
|
||||||
if (session.expiresAt <= now && session.status === 'pending') {
|
|
||||||
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>
|
|
||||||
<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>You can close this tab and return to Scoreko.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
|
||||||
const rawBody = await response.text();
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
|
||||||
} catch {
|
|
||||||
return { message: rawBody };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exchangeOAuthCodeForToken = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
oauthConfig: OAuthConfig,
|
|
||||||
): Promise<string> => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
client_id: oauthConfig.clientId,
|
|
||||||
client_secret: oauthConfig.clientSecret,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastError = 'Unknown OAuth token exchange error';
|
|
||||||
|
|
||||||
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
|
|
||||||
const response = await fetch(tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await parseOAuthTokenPayload(response);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const token = String(payload.access_token || '').trim();
|
|
||||||
if (token) {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastError = payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastError = payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`;
|
|
||||||
|
|
||||||
if (response.status !== 404) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(lastError);
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.expiresAt <= Date.now()) {
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'expired' });
|
|
||||||
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'error', error });
|
|
||||||
respondWithCallbackHtml(res, 400, 'OAuth canceled', `start.gg returned this error: ${error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
updateOAuthSession(session.sessionId, {
|
|
||||||
status: 'error',
|
|
||||||
error: 'Missing authorization code',
|
|
||||||
});
|
|
||||||
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
|
|
||||||
.then((token) => {
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
|
|
||||||
})
|
|
||||||
.catch((exchangeError) => {
|
|
||||||
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
|
|
||||||
updateOAuthSession(session.sessionId, { status: 'error', error: message });
|
|
||||||
});
|
|
||||||
|
|
||||||
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => {
|
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
||||||
const oauthConfig = getOAuthConfig();
|
const config = getOAuthConfig();
|
||||||
if (!oauthConfig) {
|
if (!config) {
|
||||||
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.');
|
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 oauthServer.ensureServer(config);
|
||||||
} catch (serverError) {
|
} catch (err) {
|
||||||
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
|
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
|
||||||
sendAck(ack, message);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupExpiredOAuthSessions();
|
const session = oauthServer.createSession(config);
|
||||||
|
sendAck(ack, null, session);
|
||||||
const sessionId = randomUUID();
|
|
||||||
const state = randomUUID();
|
|
||||||
const session: OAuthSession = {
|
|
||||||
sessionId,
|
|
||||||
state,
|
|
||||||
expiresAt: Date.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) => {
|
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||||
cleanupExpiredOAuthSessions();
|
|
||||||
|
|
||||||
const sessionId = getStringProp(payload, 'sessionId');
|
const sessionId = getStringProp(payload, 'sessionId');
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
sendAck(ack, 'Missing OAuth session id');
|
sendAck(ack, 'Missing OAuth session id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = oauthSessions.get(sessionId);
|
const status = oauthServer.getSessionStatus(sessionId);
|
||||||
if (!session) {
|
if (!status) {
|
||||||
sendAck(ack, 'OAuth session not found');
|
sendAck(ack, 'OAuth session not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAck(ack, null, {
|
sendAck(ack, null, status);
|
||||||
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) => {
|
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||||
const token = getStringProp(payload, 'token');
|
const token = getStringProp(payload, 'token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
sendAck(ack, 'Missing start.gg API token');
|
sendAck(ack, 'Missing start.gg API token');
|
||||||
return;
|
return;
|
||||||
@@ -423,21 +267,15 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack)
|
|||||||
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
||||||
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
||||||
|
|
||||||
const tournaments = data.currentUser?.tournaments.nodes
|
const tournaments =
|
||||||
.filter((item) => item.slug)
|
data.currentUser?.tournaments.nodes
|
||||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
.filter((item) => item.slug)
|
||||||
.map((item) => ({
|
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||||
id: item.id,
|
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
|
||||||
name: item.name,
|
|
||||||
slug: item.slug,
|
|
||||||
startAt: item.startAt,
|
|
||||||
endAt: item.endAt,
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
sendAck(ack, null, tournaments);
|
sendAck(ack, null, tournaments);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -445,15 +283,8 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
|
|||||||
const token = getStringProp(payload, 'token');
|
const token = getStringProp(payload, 'token');
|
||||||
const slug = getStringProp(payload, 'slug');
|
const slug = getStringProp(payload, 'slug');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
|
||||||
sendAck(ack, 'Missing start.gg API token');
|
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
sendAck(ack, 'Missing tournament slug');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
||||||
@@ -491,50 +322,37 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
|
|||||||
id: number;
|
id: number;
|
||||||
gamerTag: string | null;
|
gamerTag: string | null;
|
||||||
prefix: string | null;
|
prefix: string | null;
|
||||||
user: {
|
user: { location: { country: string | null } | null } | null;
|
||||||
location: {
|
|
||||||
country: string | null;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
} | null;
|
} | null;
|
||||||
}>(query, {
|
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
|
||||||
slug,
|
|
||||||
page: currentPage,
|
|
||||||
perPage: PARTICIPANTS_PAGE_SIZE,
|
|
||||||
}, token);
|
|
||||||
|
|
||||||
if (!data.tournament) {
|
if (!data.tournament) throw new Error('Tournament not found');
|
||||||
throw new Error('Tournament not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
|
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
|
||||||
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
|
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
|
||||||
|
|
||||||
data.tournament.participants.nodes.forEach((participant) => {
|
for (const participant of data.tournament.participants.nodes) {
|
||||||
const playerId = String(participant.id);
|
const playerId = String(participant.id);
|
||||||
const gamertag = (participant.gamerTag || '').trim();
|
const gamertag = (participant.gamerTag ?? '').trim();
|
||||||
if (!gamertag) {
|
if (!gamertag) continue;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
|
|
||||||
playersMap.set(playerId, {
|
playersMap.set(playerId, {
|
||||||
id: playerId,
|
id: playerId,
|
||||||
gamertag,
|
gamertag,
|
||||||
name: gamertag,
|
name: gamertag,
|
||||||
team: (participant.prefix || '').trim(),
|
team: (participant.prefix ?? '').trim(),
|
||||||
country,
|
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
|
||||||
twitter: '',
|
twitter: '',
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
currentPage += 1;
|
currentPage += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAck(ack, null, Array.from(playersMap.values()));
|
sendAck(ack, null, Array.from(playersMap.values()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { createServer, type Server, type ServerResponse } from 'node:http';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
// ─── Tipos públicos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OAuthConfig {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
callbackPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthSessionStatus {
|
||||||
|
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||||
|
token?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionResult {
|
||||||
|
sessionId: string;
|
||||||
|
authUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthServerOptions {
|
||||||
|
/** Nombre legible del proveedor, usado en mensajes y HTML del callback */
|
||||||
|
provider: string;
|
||||||
|
/** Ruta del callback OAuth, p.ej. '/startgg/callback' */
|
||||||
|
callbackPath: string;
|
||||||
|
/** URL del endpoint de autorización del proveedor */
|
||||||
|
authorizeEndpoint: string;
|
||||||
|
/** Scopes separados por espacio */
|
||||||
|
scope: string;
|
||||||
|
/** Milisegundos antes de que una sesión pendiente expire */
|
||||||
|
sessionTtlMs: number;
|
||||||
|
/**
|
||||||
|
* Intercambia un código de autorización por un access token.
|
||||||
|
* Lanza un error si el intercambio falla.
|
||||||
|
*/
|
||||||
|
exchangeToken: (code: string, redirectUri: string, config: OAuthConfig) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthServerHandle {
|
||||||
|
/** Arranca el servidor de callback si aún no está corriendo */
|
||||||
|
ensureServer(config: OAuthConfig): Promise<void>;
|
||||||
|
/** Crea una nueva sesión OAuth y devuelve sessionId + URL de autorización */
|
||||||
|
createSession(config: OAuthConfig): CreateSessionResult;
|
||||||
|
/** Devuelve el estado actual de una sesión, o null si no existe */
|
||||||
|
getSessionStatus(sessionId: string): OAuthSessionStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tipos internos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface OAuthSession {
|
||||||
|
sessionId: string;
|
||||||
|
state: string;
|
||||||
|
expiresAt: number;
|
||||||
|
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||||
|
token?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTML de callback ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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>You can close this tab and return to Scoreko.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Factory principal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const createOAuthServer = (options: OAuthServerOptions): OAuthServerHandle => {
|
||||||
|
const sessions = new Map<string, OAuthSession>();
|
||||||
|
let server: Server | null = null;
|
||||||
|
|
||||||
|
const getCallbackUrl = (port: number) =>
|
||||||
|
`http://127.0.0.1:${port}${options.callbackPath}`;
|
||||||
|
|
||||||
|
const updateSession = (sessionId: string, update: Partial<OAuthSession>) => {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
sessions.set(sessionId, { ...session, ...update });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca como expiradas las sesiones pendientes que han superado su TTL,
|
||||||
|
* y elimina del Map las sesiones ya terminadas (completed / error / expired)
|
||||||
|
* que también hayan superado su TTL.
|
||||||
|
*/
|
||||||
|
const cleanupSessions = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
sessions.forEach((session, sessionId) => {
|
||||||
|
if (session.expiresAt > now) return;
|
||||||
|
|
||||||
|
if (session.status === 'pending') {
|
||||||
|
updateSession(sessionId, { status: 'expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar sesiones terminadas que ya hayan expirado para no crecer sin límite
|
||||||
|
if (session.status !== 'pending') {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureServer = async (config: OAuthConfig): Promise<void> => {
|
||||||
|
if (server) return;
|
||||||
|
|
||||||
|
const callbackUrl = getCallbackUrl(config.callbackPort);
|
||||||
|
|
||||||
|
const newServer = createServer((req, res) => {
|
||||||
|
if (!req.url) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Bad request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(req.url, callbackUrl);
|
||||||
|
if (requestUrl.pathname !== options.callbackPath) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupSessions();
|
||||||
|
|
||||||
|
const state = requestUrl.searchParams.get('state') ?? '';
|
||||||
|
const code = requestUrl.searchParams.get('code') ?? '';
|
||||||
|
const error = requestUrl.searchParams.get('error') ?? '';
|
||||||
|
|
||||||
|
const session = Array.from(sessions.values()).find((s) => s.state === state);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
respondWithCallbackHtml(
|
||||||
|
res, 400,
|
||||||
|
'Invalid OAuth',
|
||||||
|
'No active session was found for this authorization.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.expiresAt <= Date.now()) {
|
||||||
|
updateSession(session.sessionId, { status: 'expired' });
|
||||||
|
respondWithCallbackHtml(
|
||||||
|
res, 400,
|
||||||
|
'Session expired',
|
||||||
|
'The OAuth session expired. Start the process again from Scoreko.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
updateSession(session.sessionId, { status: 'error', error });
|
||||||
|
respondWithCallbackHtml(
|
||||||
|
res, 400,
|
||||||
|
'OAuth canceled',
|
||||||
|
`${options.provider} returned this error: ${error}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
updateSession(session.sessionId, { status: 'error', error: 'Missing authorization code' });
|
||||||
|
respondWithCallbackHtml(
|
||||||
|
res, 400,
|
||||||
|
'Incomplete OAuth',
|
||||||
|
'No authorization code was received.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void options
|
||||||
|
.exchangeToken(code, callbackUrl, config)
|
||||||
|
.then((token) => {
|
||||||
|
updateSession(session.sessionId, { status: 'completed', token, error: undefined });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Failed to exchange authorization code';
|
||||||
|
updateSession(session.sessionId, { status: 'error', error: message });
|
||||||
|
});
|
||||||
|
|
||||||
|
respondWithCallbackHtml(
|
||||||
|
res, 200,
|
||||||
|
'Authorization received',
|
||||||
|
'Your authorization was received. Finishing sign-in in the background...',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si el servidor sufre un error tras arrancar, resetear la referencia
|
||||||
|
// para que la próxima llamada a ensureServer() pueda reiniciarlo.
|
||||||
|
newServer.on('error', (err) => {
|
||||||
|
console.error(`[${options.provider}] OAuth callback server error:`, err);
|
||||||
|
server = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
newServer.once('error', reject);
|
||||||
|
newServer.listen(config.callbackPort, '127.0.0.1', () => {
|
||||||
|
newServer.off('error', reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server = newServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSession = (config: OAuthConfig): CreateSessionResult => {
|
||||||
|
cleanupSessions();
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const state = randomUUID();
|
||||||
|
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
state,
|
||||||
|
expiresAt: Date.now() + options.sessionTtlMs,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: config.clientId,
|
||||||
|
redirect_uri: getCallbackUrl(config.callbackPort),
|
||||||
|
scope: options.scope,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
authUrl: `${options.authorizeEndpoint}?${params.toString()}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionStatus = (sessionId: string): OAuthSessionStatus | null => {
|
||||||
|
cleanupSessions();
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: session.status,
|
||||||
|
token: session.status === 'completed' ? session.token : undefined,
|
||||||
|
error: session.status === 'error' ? session.error : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ensureServer, createSession, getSessionStatus };
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 738 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 226 KiB |
@@ -10,6 +10,51 @@ const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
|
|||||||
const MAX_INITIALS = 2;
|
const MAX_INITIALS = 2;
|
||||||
|
|
||||||
const characterNamesByGame: Record<string, string[]> = {
|
const characterNamesByGame: Record<string, string[]> = {
|
||||||
|
'2XKO': [
|
||||||
|
'Ahri',
|
||||||
|
'Akali',
|
||||||
|
'Braum',
|
||||||
|
'Caitlyn',
|
||||||
|
'Darius',
|
||||||
|
'Ekko',
|
||||||
|
'Illaoi',
|
||||||
|
'Jinx',
|
||||||
|
'Senna',
|
||||||
|
'Teemo',
|
||||||
|
'Vi',
|
||||||
|
'Warwick',
|
||||||
|
'Yasuo',
|
||||||
|
],
|
||||||
|
'FATAL FURY: City of the Wolves': [
|
||||||
|
'Andy Bogard',
|
||||||
|
'B. Jenet',
|
||||||
|
'Billy Kane',
|
||||||
|
'Blue Mary',
|
||||||
|
'Chun-Li',
|
||||||
|
'Cristiano Ronaldo',
|
||||||
|
'Gato',
|
||||||
|
'Hokutomaru',
|
||||||
|
'Hotaru Futaba',
|
||||||
|
'Joe Higashi',
|
||||||
|
'Kain R. Heinlein',
|
||||||
|
'Ken Masters',
|
||||||
|
'Kenshiro',
|
||||||
|
'Kevin Rian',
|
||||||
|
'Kim Dong Hwan',
|
||||||
|
'Kim Jae Hoon',
|
||||||
|
'Mai Shiranui',
|
||||||
|
'Marco Rodrigues',
|
||||||
|
'Mr. Big',
|
||||||
|
'Mr. Karate',
|
||||||
|
'Nightmare Geese',
|
||||||
|
'Preecha',
|
||||||
|
'Rock Howard',
|
||||||
|
'Salvatore Ganacci',
|
||||||
|
'Terry Bogard',
|
||||||
|
'Tizoc',
|
||||||
|
'Vox Reaper',
|
||||||
|
'Wolfgang Krauser',
|
||||||
|
],
|
||||||
'Guilty Gear -Strive-': [
|
'Guilty Gear -Strive-': [
|
||||||
'A.B.A',
|
'A.B.A',
|
||||||
'Anji Mito',
|
'Anji Mito',
|
||||||
@@ -44,6 +89,64 @@ const characterNamesByGame: Record<string, string[]> = {
|
|||||||
'Venom',
|
'Venom',
|
||||||
'Zato-1',
|
'Zato-1',
|
||||||
],
|
],
|
||||||
|
'Invincible VS': [
|
||||||
|
'Allen The Alien',
|
||||||
|
'Anissa',
|
||||||
|
'Atom Eve',
|
||||||
|
'Battle Beast',
|
||||||
|
'Bulletproof',
|
||||||
|
'Cecil',
|
||||||
|
'Conquest',
|
||||||
|
'Dupli-Kate',
|
||||||
|
'Ella Mental',
|
||||||
|
'Immortal',
|
||||||
|
'Invincible',
|
||||||
|
'Lucan',
|
||||||
|
'Monster Girl',
|
||||||
|
'Omni-Man',
|
||||||
|
'Power Plex',
|
||||||
|
'Rex Splode',
|
||||||
|
'Robot',
|
||||||
|
'Thula',
|
||||||
|
'Titan',
|
||||||
|
'Universa',
|
||||||
|
],
|
||||||
|
'Mortal Kombat 1': [
|
||||||
|
'Ashrah',
|
||||||
|
'Baraka',
|
||||||
|
'Conan the Barbarian',
|
||||||
|
'Cyrax',
|
||||||
|
'Ermac',
|
||||||
|
'Geras',
|
||||||
|
'Ghostface',
|
||||||
|
'Havik',
|
||||||
|
'Homelander',
|
||||||
|
'Johnny Cage',
|
||||||
|
'Kenshi',
|
||||||
|
'Kitana',
|
||||||
|
'Kung Lao',
|
||||||
|
'Li Mei',
|
||||||
|
'Liu Kang',
|
||||||
|
'Mileena',
|
||||||
|
'Nitara',
|
||||||
|
'Noob Saibot',
|
||||||
|
'Omni-Man',
|
||||||
|
'Peacemaker',
|
||||||
|
'Quan Chi',
|
||||||
|
'Raiden',
|
||||||
|
'Rain',
|
||||||
|
'Reiko',
|
||||||
|
'Reptile',
|
||||||
|
'Scorpion',
|
||||||
|
'Sektor',
|
||||||
|
'Shang Tsung',
|
||||||
|
'Sindel',
|
||||||
|
'Smoke',
|
||||||
|
'Sub-Zero',
|
||||||
|
'Takeda',
|
||||||
|
'Tanya',
|
||||||
|
'T-1000',
|
||||||
|
],
|
||||||
'Street Fighter 6': [
|
'Street Fighter 6': [
|
||||||
'A.K.I.',
|
'A.K.I.',
|
||||||
'Akuma',
|
'Akuma',
|
||||||
@@ -120,57 +223,6 @@ const characterNamesByGame: Record<string, string[]> = {
|
|||||||
'Yoshimitsu',
|
'Yoshimitsu',
|
||||||
'Zafina',
|
'Zafina',
|
||||||
],
|
],
|
||||||
'2XKO': [
|
|
||||||
'Ahri',
|
|
||||||
'Akali',
|
|
||||||
'Braum',
|
|
||||||
'Caitlyn',
|
|
||||||
'Darius',
|
|
||||||
'Ekko',
|
|
||||||
'Illaoi',
|
|
||||||
'Jinx',
|
|
||||||
'Senna',
|
|
||||||
'Teemo',
|
|
||||||
'Vi',
|
|
||||||
'Warwick',
|
|
||||||
'Yasuo',
|
|
||||||
],
|
|
||||||
'Mortal Kombat 1': [
|
|
||||||
'Ashrah',
|
|
||||||
'Baraka',
|
|
||||||
'Conan the Barbarian',
|
|
||||||
'Cyrax',
|
|
||||||
'Ermac',
|
|
||||||
'Geras',
|
|
||||||
'Ghostface',
|
|
||||||
'Havik',
|
|
||||||
'Homelander',
|
|
||||||
'Johnny Cage',
|
|
||||||
'Kenshi',
|
|
||||||
'Kitana',
|
|
||||||
'Kung Lao',
|
|
||||||
'Li Mei',
|
|
||||||
'Liu Kang',
|
|
||||||
'Mileena',
|
|
||||||
'Nitara',
|
|
||||||
'Noob Saibot',
|
|
||||||
'Omni-Man',
|
|
||||||
'Peacemaker',
|
|
||||||
'Quan Chi',
|
|
||||||
'Raiden',
|
|
||||||
'Rain',
|
|
||||||
'Reiko',
|
|
||||||
'Reptile',
|
|
||||||
'Scorpion',
|
|
||||||
'Sektor',
|
|
||||||
'Shang Tsung',
|
|
||||||
'Sindel',
|
|
||||||
'Smoke',
|
|
||||||
'Sub-Zero',
|
|
||||||
'Takeda',
|
|
||||||
'Tanya',
|
|
||||||
'T-1000',
|
|
||||||
],
|
|
||||||
'THE KING OF FIGHTERS XV': [
|
'THE KING OF FIGHTERS XV': [
|
||||||
'Angel',
|
'Angel',
|
||||||
'Antonov',
|
'Antonov',
|
||||||
|
|||||||