mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
feat: implement start.gg OAuth integration and services
- Added start.gg OAuth server and session management in startgg.ts - Implemented functions to fetch recent tournaments and tournament players from start.gg - Created utility functions for string and country code handling - Introduced Challonge OAuth server and services for tournament data fetching - Refactored shared types and utility functions for better organization - Updated scoreboard graphics to use new country resolution utilities - Removed legacy startgg.ts file to streamline codebase
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
# Summary: Phase 1 (Base Architecture)
|
||||||
|
|
||||||
|
## Objetivos Completados
|
||||||
|
- **Reorganización Estructural**: Se movieron utilidades y tipos compartidos a `src/shared/utils/` y `src/shared/types/`.
|
||||||
|
- **Desacoplamiento del Backend**: Se eliminaron los monolitos `startgg.ts` y `challonge.ts` de `src/extension/`.
|
||||||
|
- **Creación de Capas**:
|
||||||
|
- `api/`: Llamadas aisladas de GraphQL y HTTP (`startgg.api.ts`, `challonge.api.ts`).
|
||||||
|
- `oauth/`: Lógica de autenticación OAuth manejada independientemente.
|
||||||
|
- `services/`: Lógica de dominio pura para transformar y parsear respuestas (ej. extraer `RecentTournament` y `ImportedPlayer`).
|
||||||
|
- `nodecg-bindings/`: Registros exclusivos de `nodecg.listenFor(...)` sin mezclar lógica de dominio.
|
||||||
|
- **Tipado Fuerte**: Se crearon interfaces centralizadas en `src/shared/types/domain.ts` asegurando tipos explícitos y la ausencia de `any`.
|
||||||
|
- **Consolidación**: Duplicidades como la resolución de códigos de país y parseo de strings (ej. `getStringProp`) se extrajeron a utilidades de `shared`.
|
||||||
|
|
||||||
|
## Ajustes Técnicos Realizados
|
||||||
|
- El `tsconfig.extension.json` fue ajustado (`rootDir: "./src"`, `outDir: "./"`) para permitir que la compilación backend (`tsc`) incluya e integre los archivos de `src/shared/` de forma nativa sin romper la estructura requerida por NodeCG (que espera los archivos compilados del backend en el directorio raíz `extension/`).
|
||||||
|
- Actualización de todos los *imports* en vistas (`Players.vue`), *composables* (`useCountryFilter.ts`) y gráficos (`main.vue`).
|
||||||
|
- Compilación (`npm run build`) verificada y validada sin errores de TypeScript.
|
||||||
|
|
||||||
|
## Siguientes Pasos Requeridos
|
||||||
|
- Avanzar a la **Fase 2**: Refactor del Estado del Dashboard (Stores), simplificando `store-sync.ts` e hidratando Pinia directamente desde los *Replicants*.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const getStringProp = (payload, key) => {
|
||||||
|
if (typeof payload !== 'object' || payload === null || !(key in payload))
|
||||||
|
return '';
|
||||||
|
const value = payload[key];
|
||||||
|
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
|
};
|
||||||
|
export const getNumberProp = (payload, keys) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
export const normalizeTournamentSlug = (value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
return '';
|
||||||
|
return trimmed
|
||||||
|
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||||
|
.replace(/^tournaments\//i, '')
|
||||||
|
.replace(/^\/+/, '');
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
|
import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries';
|
||||||
import { locale } from '../i18n';
|
import { locale } from '../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { useQuasar, type QTableColumn } from 'quasar';
|
import { useQuasar, type QTableColumn } from 'quasar';
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
|
import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries';
|
||||||
import type { Schemas } from '../../../types';
|
import type { Schemas } from '../../../types';
|
||||||
import { useIntegration } from '../composables/useIntegration';
|
import { useIntegration } from '../composables/useIntegration';
|
||||||
import { locale, t } from '../i18n';
|
import { locale, t } from '../i18n';
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1';
|
||||||
|
|
||||||
|
export type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null;
|
||||||
|
|
||||||
|
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
||||||
|
const rawBody = await response.text();
|
||||||
|
if (!rawBody) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
||||||
|
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
||||||
|
|
||||||
|
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
|
||||||
|
const v2Response = await fetch(requestUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/vnd.api+json',
|
||||||
|
'Authorization-Type': 'v2',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const v2Payload = await parseJsonResponse(v2Response);
|
||||||
|
|
||||||
|
if (v2Response.ok) {
|
||||||
|
return v2Payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
|
||||||
|
if (v2Response.status === 401) {
|
||||||
|
const v1Response = await fetch(requestUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/vnd.api+json',
|
||||||
|
'Authorization-Type': 'v1',
|
||||||
|
Authorization: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const v1Payload = await parseJsonResponse(v1Response);
|
||||||
|
|
||||||
|
if (v1Response.ok) {
|
||||||
|
return v1Payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const v1Error = v1Payload as ChallongeErrorPayload;
|
||||||
|
throw new Error(
|
||||||
|
v1Error?.errors?.detail ??
|
||||||
|
v1Error?.error ??
|
||||||
|
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
|
||||||
|
|
||||||
|
export interface StartGGGraphQLResponse<T> {
|
||||||
|
data?: T;
|
||||||
|
errors?: Array<{ message?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestStartGG = async <T>(
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
token: string,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await fetch(STARTGG_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: StartGGGraphQLResponse<T>;
|
||||||
|
try {
|
||||||
|
payload = (await response.json()) as StartGGGraphQLResponse<T>;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON response from start.gg');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.errors?.length) {
|
||||||
|
throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.data) {
|
||||||
|
throw new Error('No data returned by start.gg');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data;
|
||||||
|
};
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
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_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
|
|
||||||
const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token';
|
|
||||||
const CHALLONGE_OAUTH_SCOPES = [
|
|
||||||
'me',
|
|
||||||
'tournaments:read',
|
|
||||||
'tournaments:write',
|
|
||||||
'matches:read',
|
|
||||||
'matches:write',
|
|
||||||
'participants:read',
|
|
||||||
'participants:write',
|
|
||||||
].join(' ');
|
|
||||||
const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
|
|
||||||
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
|
|
||||||
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
|
||||||
const RECENT_TOURNAMENTS_LIMIT = 20;
|
|
||||||
|
|
||||||
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
|
|
||||||
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
|
|
||||||
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
|
|
||||||
//
|
|
||||||
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
|
|
||||||
// (útil para apuntar a un entorno de staging sin recompilar).
|
|
||||||
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
|
||||||
|
|
||||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface OAuthTokenResponse {
|
|
||||||
access_token?: string;
|
|
||||||
error?: string;
|
|
||||||
error_description?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentTournament {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
startAt: number | null;
|
|
||||||
endAt: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportedPlayer {
|
|
||||||
id: string;
|
|
||||||
gamertag: string;
|
|
||||||
name: string;
|
|
||||||
team: string;
|
|
||||||
country: string;
|
|
||||||
twitter: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// DEV: cfg/scoreko.json tiene challongeClientId + challongeClientSecret.
|
|
||||||
// El exchange se hace directamente contra Challonge.
|
|
||||||
//
|
|
||||||
// PROXY: No hay credenciales en la config local.
|
|
||||||
// El clientId se obtiene del Worker (es público, no secreto).
|
|
||||||
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
|
|
||||||
|
|
||||||
type OAuthMode =
|
|
||||||
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
|
||||||
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
|
||||||
|
|
||||||
const getOAuthMode = (): OAuthMode => {
|
|
||||||
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
|
|
||||||
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
|
|
||||||
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
|
|
||||||
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
|
|
||||||
const callbackPort =
|
|
||||||
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
|
||||||
|
|
||||||
const proxyBaseUrl =
|
|
||||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
|
||||||
|
|
||||||
if (clientId && clientSecret) {
|
|
||||||
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
|
|
||||||
return { type: 'dev', clientId, clientSecret, callbackPort };
|
|
||||||
}
|
|
||||||
|
|
||||||
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
|
|
||||||
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Modo dev: exchange directo con Challonge usando credenciales locales */
|
|
||||||
const exchangeCodeDirectly = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
clientId: string,
|
|
||||||
clientSecret: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawBody = await response.text();
|
|
||||||
let payload: OAuthTokenResponse;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(rawBody) as OAuthTokenResponse;
|
|
||||||
} catch {
|
|
||||||
payload = { message: rawBody };
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
throw new Error(
|
|
||||||
payload.error_description ??
|
|
||||||
payload.error ??
|
|
||||||
payload.message ??
|
|
||||||
'OAuth token response did not include an access token',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
|
|
||||||
const exchangeCodeViaProxy = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
proxyBaseUrl: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ code, redirectUri }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawBody = await response.text();
|
|
||||||
let payload: { access_token?: string; error?: string };
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(rawBody) as typeof payload;
|
|
||||||
} catch {
|
|
||||||
payload = { error: rawBody };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
|
||||||
}
|
|
||||||
const token = String(payload.access_token ?? '').trim();
|
|
||||||
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
|
|
||||||
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
|
|
||||||
*/
|
|
||||||
const exchangeOAuthCodeForToken = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
_config: OAuthConfig,
|
|
||||||
): Promise<string> => {
|
|
||||||
const mode = getOAuthMode();
|
|
||||||
if (mode.type === 'dev') {
|
|
||||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
|
||||||
}
|
|
||||||
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── 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 rawBody = await response.text();
|
|
||||||
if (!rawBody) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawBody) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
|
||||||
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
|
||||||
|
|
||||||
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
|
|
||||||
const v2Response = await fetch(requestUrl, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/vnd.api+json',
|
|
||||||
'Authorization-Type': 'v2',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const v2Payload = await parseJsonResponse(v2Response);
|
|
||||||
|
|
||||||
if (v2Response.ok) {
|
|
||||||
return v2Payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
|
|
||||||
if (v2Response.status === 401) {
|
|
||||||
const v1Response = await fetch(requestUrl, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/vnd.api+json',
|
|
||||||
'Authorization-Type': 'v1',
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const v1Payload = await parseJsonResponse(v1Response);
|
|
||||||
|
|
||||||
if (v1Response.ok) {
|
|
||||||
return v1Payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
const v1Error = v1Payload as ChallongeErrorPayload;
|
|
||||||
throw new Error(
|
|
||||||
v1Error?.errors?.detail ??
|
|
||||||
v1Error?.error ??
|
|
||||||
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 trimmed = value.trim();
|
|
||||||
if (!trimmed) 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 null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
|
||||||
const rows: RecentTournament[] = [];
|
|
||||||
|
|
||||||
const push = (candidate: Record<string, unknown>) => {
|
|
||||||
const attributes =
|
|
||||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
|
||||||
? (candidate.attributes as Record<string, unknown>)
|
|
||||||
: candidate;
|
|
||||||
|
|
||||||
const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim();
|
|
||||||
const name = String(attributes.name ?? attributes.full_name ?? '').trim();
|
|
||||||
const slug = normalizeTournamentSlug(
|
|
||||||
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!id || !name || !slug) return;
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
startAt: getNumberProp(attributes, ['start_at', 'started_at', 'startAt']),
|
|
||||||
endAt: getNumberProp(attributes, ['completed_at', 'end_at', 'ended_at', 'endAt']),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
for (const row of payload) {
|
|
||||||
const wrapper = row as Record<string, unknown>;
|
|
||||||
const tournament =
|
|
||||||
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
|
|
||||||
? (wrapper.tournament as Record<string, unknown>)
|
|
||||||
: wrapper;
|
|
||||||
push(tournament);
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload === 'object' && payload !== null) {
|
|
||||||
const data = (payload as Record<string, unknown>).data;
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
for (const row of data) {
|
|
||||||
if (typeof row === 'object' && row !== null) {
|
|
||||||
push(row as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
|
|
||||||
const map = new Map<string, ImportedPlayer>();
|
|
||||||
|
|
||||||
const push = (candidate: Record<string, unknown>) => {
|
|
||||||
const attributes =
|
|
||||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
|
||||||
? (candidate.attributes as Record<string, unknown>)
|
|
||||||
: candidate;
|
|
||||||
|
|
||||||
const id = String(
|
|
||||||
candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
const rawDisplayName = String(
|
|
||||||
attributes.display_name ??
|
|
||||||
attributes.name ??
|
|
||||||
attributes.username ??
|
|
||||||
attributes.gamer_tag ??
|
|
||||||
'',
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
if (!id || !rawDisplayName) return;
|
|
||||||
|
|
||||||
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
|
|
||||||
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
|
|
||||||
|
|
||||||
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
|
|
||||||
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
|
|
||||||
const team = String(attributes.team_name ?? '').trim() || teamFromName;
|
|
||||||
|
|
||||||
map.set(id, {
|
|
||||||
id,
|
|
||||||
gamertag,
|
|
||||||
name: '',
|
|
||||||
team,
|
|
||||||
country: '',
|
|
||||||
twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
for (const row of payload) {
|
|
||||||
const wrapper = row as Record<string, unknown>;
|
|
||||||
const participant =
|
|
||||||
typeof wrapper.participant === 'object' && wrapper.participant !== null
|
|
||||||
? (wrapper.participant as Record<string, unknown>)
|
|
||||||
: wrapper;
|
|
||||||
push(participant);
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload === 'object' && payload !== null) {
|
|
||||||
const data = (payload as Record<string, unknown>).data;
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
for (const row of data) {
|
|
||||||
if (typeof row === 'object' && row !== null) {
|
|
||||||
push(row as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(map.values());
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── 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) => {
|
|
||||||
if (typeof ack === 'function') ack(error, response);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
|
||||||
const mode = getOAuthMode();
|
|
||||||
let serverConfig: OAuthConfig;
|
|
||||||
|
|
||||||
if (mode.type === 'dev') {
|
|
||||||
serverConfig = {
|
|
||||||
clientId: mode.clientId,
|
|
||||||
callbackPort: mode.callbackPort,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Modo proxy: el clientId viene del Worker (es público, no secreto)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
|
|
||||||
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
|
||||||
const data = await res.json() as { clientId?: string };
|
|
||||||
const clientId = String(data.clientId ?? '').trim();
|
|
||||||
if (!clientId) throw new Error('Proxy did not return a clientId');
|
|
||||||
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
|
||||||
} catch (err) {
|
|
||||||
sendAck(
|
|
||||||
ack,
|
|
||||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await oauthServer.ensureServer(serverConfig);
|
|
||||||
} catch (err) {
|
|
||||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
|
||||||
});
|
|
||||||
|
|
||||||
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
|
||||||
const sessionId = getStringProp(payload, 'sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
sendAck(ack, 'Missing OAuth session id');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = oauthServer.getSessionStatus(sessionId);
|
|
||||||
if (!status) {
|
|
||||||
sendAck(ack, 'OAuth session not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAck(ack, null, status);
|
|
||||||
});
|
|
||||||
|
|
||||||
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
|
||||||
const token = getStringProp(payload, 'token');
|
|
||||||
if (!token) {
|
|
||||||
sendAck(ack, 'Missing Challonge API token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = await requestChallonge('/tournaments.json', token);
|
|
||||||
const tournaments = parseRecentTournaments(raw)
|
|
||||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
|
||||||
.slice(0, RECENT_TOURNAMENTS_LIMIT);
|
|
||||||
sendAck(ack, null, tournaments);
|
|
||||||
} catch (error) {
|
|
||||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
|
||||||
const token = getStringProp(payload, 'token');
|
|
||||||
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
|
||||||
|
|
||||||
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
|
|
||||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = await requestChallonge(
|
|
||||||
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
sendAck(ack, null, parseImportedPlayers(raw));
|
|
||||||
} catch (error) {
|
|
||||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,7 @@ export default async (nodecg: NodeCGServerAPI) => {
|
|||||||
set(nodecg); // set nodecg "context" before anything else
|
set(nodecg); // set nodecg "context" before anything else
|
||||||
await import('./util/replicants.js'); // make sure replicants are set up
|
await import('./util/replicants.js'); // make sure replicants are set up
|
||||||
await import('./example.js');
|
await import('./example.js');
|
||||||
await import('./startgg.js');
|
await import('./nodecg-bindings/startgg.js');
|
||||||
await import('./challonge.js');
|
await import('./nodecg-bindings/challonge.js');
|
||||||
await import('./pack-manager.js');
|
await import('./pack-manager.js');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { nodecg } from '../util/nodecg.js';
|
||||||
|
import { getStringProp, normalizeTournamentSlug } from '../../shared/utils/string.js';
|
||||||
|
import { challongeOAuthServer, getOAuthMode } from '../oauth/challonge.js';
|
||||||
|
import { fetchRecentTournaments, fetchTournamentPlayers } from '../services/challonge.js';
|
||||||
|
import type { OAuthConfig } from '../util/oauth-server.js';
|
||||||
|
|
||||||
|
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||||
|
if (typeof ack === 'function') ack(error, response);
|
||||||
|
};
|
||||||
|
|
||||||
|
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
||||||
|
const mode = getOAuthMode();
|
||||||
|
let serverConfig: OAuthConfig;
|
||||||
|
|
||||||
|
if (mode.type === 'dev') {
|
||||||
|
serverConfig = {
|
||||||
|
clientId: mode.clientId,
|
||||||
|
callbackPort: mode.callbackPort,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
|
||||||
|
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
||||||
|
const data = await res.json() as { clientId?: string };
|
||||||
|
const clientId = String(data.clientId ?? '').trim();
|
||||||
|
if (!clientId) throw new Error('Proxy did not return a clientId');
|
||||||
|
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
||||||
|
} catch (err) {
|
||||||
|
sendAck(
|
||||||
|
ack,
|
||||||
|
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await challongeOAuthServer.ensureServer(serverConfig);
|
||||||
|
} catch (err) {
|
||||||
|
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAck(ack, null, challongeOAuthServer.createSession(serverConfig));
|
||||||
|
});
|
||||||
|
|
||||||
|
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||||
|
const sessionId = getStringProp(payload, 'sessionId');
|
||||||
|
if (!sessionId) {
|
||||||
|
sendAck(ack, 'Missing OAuth session id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = challongeOAuthServer.getSessionStatus(sessionId);
|
||||||
|
if (!status) {
|
||||||
|
sendAck(ack, 'OAuth session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAck(ack, null, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||||
|
const token = getStringProp(payload, 'token');
|
||||||
|
if (!token) {
|
||||||
|
sendAck(ack, 'Missing Challonge API token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tournaments = await fetchRecentTournaments(token);
|
||||||
|
sendAck(ack, null, tournaments);
|
||||||
|
} catch (error) {
|
||||||
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||||
|
const token = getStringProp(payload, 'token');
|
||||||
|
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
||||||
|
|
||||||
|
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
|
||||||
|
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const players = await fetchTournamentPlayers(slug, token);
|
||||||
|
sendAck(ack, null, players);
|
||||||
|
} catch (error) {
|
||||||
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { nodecg } from '../util/nodecg.js';
|
||||||
|
import { getStringProp } from '../../shared/utils/string.js';
|
||||||
|
import { startggOAuthServer, getOAuthMode } from '../oauth/startgg.js';
|
||||||
|
import { fetchRecentTournaments, fetchTournamentPlayers } from '../services/startgg.js';
|
||||||
|
import type { OAuthConfig } from '../util/oauth-server.js';
|
||||||
|
|
||||||
|
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||||
|
if (typeof ack === 'function') ack(error, response);
|
||||||
|
};
|
||||||
|
|
||||||
|
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
||||||
|
const mode = getOAuthMode();
|
||||||
|
let serverConfig: OAuthConfig;
|
||||||
|
|
||||||
|
if (mode.type === 'dev') {
|
||||||
|
serverConfig = {
|
||||||
|
clientId: mode.clientId,
|
||||||
|
callbackPort: mode.callbackPort,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`);
|
||||||
|
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
||||||
|
const data = await res.json() as { clientId?: string };
|
||||||
|
const clientId = String(data.clientId ?? '').trim();
|
||||||
|
if (!clientId) throw new Error('Proxy did not return a clientId');
|
||||||
|
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
||||||
|
} catch (err) {
|
||||||
|
sendAck(
|
||||||
|
ack,
|
||||||
|
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startggOAuthServer.ensureServer(serverConfig);
|
||||||
|
} catch (err) {
|
||||||
|
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAck(ack, null, startggOAuthServer.createSession(serverConfig));
|
||||||
|
});
|
||||||
|
|
||||||
|
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||||
|
const sessionId = getStringProp(payload, 'sessionId');
|
||||||
|
if (!sessionId) {
|
||||||
|
sendAck(ack, 'Missing OAuth session id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = startggOAuthServer.getSessionStatus(sessionId);
|
||||||
|
if (!status) {
|
||||||
|
sendAck(ack, 'OAuth session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAck(ack, null, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||||
|
const token = getStringProp(payload, 'token');
|
||||||
|
if (!token) {
|
||||||
|
sendAck(ack, 'Missing start.gg API token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tournaments = await fetchRecentTournaments(token);
|
||||||
|
sendAck(ack, null, tournaments);
|
||||||
|
} catch (error) {
|
||||||
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||||
|
const token = getStringProp(payload, 'token');
|
||||||
|
const slug = getStringProp(payload, 'slug');
|
||||||
|
|
||||||
|
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
|
||||||
|
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const players = await fetchTournamentPlayers(slug, token);
|
||||||
|
sendAck(ack, null, players);
|
||||||
|
} catch (error) {
|
||||||
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { nodecg } from '../util/nodecg.js';
|
||||||
|
import { createOAuthServer, type OAuthConfig } from '../util/oauth-server.js';
|
||||||
|
import type { OAuthMode, OAuthTokenResponse } from '../../shared/types/domain.js';
|
||||||
|
|
||||||
|
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
|
||||||
|
const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token';
|
||||||
|
const CHALLONGE_OAUTH_SCOPES = [
|
||||||
|
'me',
|
||||||
|
'tournaments:read',
|
||||||
|
'tournaments:write',
|
||||||
|
'matches:read',
|
||||||
|
'matches:write',
|
||||||
|
'participants:read',
|
||||||
|
'participants:write',
|
||||||
|
].join(' ');
|
||||||
|
export const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
|
||||||
|
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
|
||||||
|
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
||||||
|
|
||||||
|
export const getOAuthMode = (): OAuthMode => {
|
||||||
|
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
|
||||||
|
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
|
||||||
|
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
|
||||||
|
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
|
||||||
|
const callbackPort =
|
||||||
|
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
||||||
|
|
||||||
|
const proxyBaseUrl =
|
||||||
|
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||||
|
|
||||||
|
if (clientId && clientSecret) {
|
||||||
|
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
|
||||||
|
return { type: 'dev', clientId, clientSecret, callbackPort };
|
||||||
|
}
|
||||||
|
|
||||||
|
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||||
|
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeCodeDirectly = async (
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawBody = await response.text();
|
||||||
|
let payload: OAuthTokenResponse;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(rawBody) as OAuthTokenResponse;
|
||||||
|
} catch {
|
||||||
|
payload = { message: rawBody };
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
throw new Error(
|
||||||
|
payload.error_description ??
|
||||||
|
payload.error ??
|
||||||
|
payload.message ??
|
||||||
|
'OAuth token response did not include an access token',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeCodeViaProxy = async (
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
proxyBaseUrl: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, redirectUri }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawBody = await response.text();
|
||||||
|
let payload: { access_token?: string; error?: string };
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(rawBody) as typeof payload;
|
||||||
|
} catch {
|
||||||
|
payload = { error: rawBody };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
||||||
|
}
|
||||||
|
const token = String(payload.access_token ?? '').trim();
|
||||||
|
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeOAuthCodeForToken = async (
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
_config: OAuthConfig,
|
||||||
|
): Promise<string> => {
|
||||||
|
const mode = getOAuthMode();
|
||||||
|
if (mode.type === 'dev') {
|
||||||
|
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||||
|
}
|
||||||
|
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const challongeOAuthServer = 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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { nodecg } from '../util/nodecg.js';
|
||||||
|
import { createOAuthServer, type OAuthConfig } from '../util/oauth-server.js';
|
||||||
|
import type { OAuthMode, OAuthTokenResponse } from '../../shared/types/domain.js';
|
||||||
|
|
||||||
|
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
|
||||||
|
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
|
||||||
|
'https://www.start.gg/api/-/rest/oauth/access_token',
|
||||||
|
'https://api.start.gg/oauth/access_token',
|
||||||
|
];
|
||||||
|
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
|
||||||
|
export const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
|
||||||
|
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
|
||||||
|
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
||||||
|
|
||||||
|
export const getOAuthMode = (): OAuthMode => {
|
||||||
|
const bundleConfig = nodecg.bundleConfig 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;
|
||||||
|
|
||||||
|
const proxyBaseUrl =
|
||||||
|
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||||
|
|
||||||
|
if (clientId && clientSecret) {
|
||||||
|
nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)');
|
||||||
|
return { type: 'dev', clientId, clientSecret, callbackPort };
|
||||||
|
}
|
||||||
|
|
||||||
|
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||||
|
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||||
|
const rawBody = await response.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||||
|
} catch {
|
||||||
|
return { message: rawBody };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeCodeDirectly = async (
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: 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 exchangeCodeViaProxy = async (
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
proxyBaseUrl: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const response = await fetch(`${proxyBaseUrl}/oauth/startgg/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, redirectUri }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawBody = await response.text();
|
||||||
|
let payload: { access_token?: string; error?: string };
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(rawBody) as typeof payload;
|
||||||
|
} catch {
|
||||||
|
payload = { error: rawBody };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
||||||
|
}
|
||||||
|
const token = String(payload.access_token ?? '').trim();
|
||||||
|
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeOAuthCodeForToken = async (
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
_config: OAuthConfig,
|
||||||
|
): Promise<string> => {
|
||||||
|
const mode = getOAuthMode();
|
||||||
|
if (mode.type === 'dev') {
|
||||||
|
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||||
|
}
|
||||||
|
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startggOAuthServer = 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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { requestChallonge } from '../api/challonge.js';
|
||||||
|
import { normalizeTournamentSlug, getNumberProp } from '../../shared/utils/string.js';
|
||||||
|
import type { RecentTournament, ImportedPlayer } from '../../shared/types/domain.js';
|
||||||
|
|
||||||
|
const RECENT_TOURNAMENTS_LIMIT = 20;
|
||||||
|
|
||||||
|
export const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
||||||
|
const rows: RecentTournament[] = [];
|
||||||
|
|
||||||
|
const push = (candidate: Record<string, unknown>) => {
|
||||||
|
const attributes =
|
||||||
|
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||||
|
? (candidate.attributes as Record<string, unknown>)
|
||||||
|
: candidate;
|
||||||
|
|
||||||
|
const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim();
|
||||||
|
const name = String(attributes.name ?? attributes.full_name ?? '').trim();
|
||||||
|
const slug = normalizeTournamentSlug(
|
||||||
|
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!id || !name || !slug) return;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
startAt: getNumberProp(attributes, ['start_at', 'started_at', 'startAt']),
|
||||||
|
endAt: getNumberProp(attributes, ['completed_at', 'end_at', 'ended_at', 'endAt']),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
for (const row of payload) {
|
||||||
|
const wrapper = row as Record<string, unknown>;
|
||||||
|
const tournament =
|
||||||
|
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
|
||||||
|
? (wrapper.tournament as Record<string, unknown>)
|
||||||
|
: wrapper;
|
||||||
|
push(tournament);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload === 'object' && payload !== null) {
|
||||||
|
const data = (payload as Record<string, unknown>).data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (const row of data) {
|
||||||
|
if (typeof row === 'object' && row !== null) {
|
||||||
|
push(row as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
|
||||||
|
const map = new Map<string, ImportedPlayer>();
|
||||||
|
|
||||||
|
const push = (candidate: Record<string, unknown>) => {
|
||||||
|
const attributes =
|
||||||
|
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||||
|
? (candidate.attributes as Record<string, unknown>)
|
||||||
|
: candidate;
|
||||||
|
|
||||||
|
const id = String(
|
||||||
|
candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const rawDisplayName = String(
|
||||||
|
attributes.display_name ??
|
||||||
|
attributes.name ??
|
||||||
|
attributes.username ??
|
||||||
|
attributes.gamer_tag ??
|
||||||
|
'',
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (!id || !rawDisplayName) return;
|
||||||
|
|
||||||
|
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
|
||||||
|
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
|
||||||
|
|
||||||
|
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
|
||||||
|
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
|
||||||
|
const team = String(attributes.team_name ?? '').trim() || teamFromName;
|
||||||
|
|
||||||
|
map.set(id, {
|
||||||
|
id,
|
||||||
|
gamertag,
|
||||||
|
name: '',
|
||||||
|
team,
|
||||||
|
country: '',
|
||||||
|
twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
for (const row of payload) {
|
||||||
|
const wrapper = row as Record<string, unknown>;
|
||||||
|
const participant =
|
||||||
|
typeof wrapper.participant === 'object' && wrapper.participant !== null
|
||||||
|
? (wrapper.participant as Record<string, unknown>)
|
||||||
|
: wrapper;
|
||||||
|
push(participant);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload === 'object' && payload !== null) {
|
||||||
|
const data = (payload as Record<string, unknown>).data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (const row of data) {
|
||||||
|
if (typeof row === 'object' && row !== null) {
|
||||||
|
push(row as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchRecentTournaments = async (token: string): Promise<RecentTournament[]> => {
|
||||||
|
const raw = await requestChallonge('/tournaments.json', token);
|
||||||
|
return parseRecentTournaments(raw)
|
||||||
|
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||||
|
.slice(0, RECENT_TOURNAMENTS_LIMIT);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchTournamentPlayers = async (slug: string, token: string): Promise<ImportedPlayer[]> => {
|
||||||
|
const raw = await requestChallonge(
|
||||||
|
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
return parseImportedPlayers(raw);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { getData, type CountryRecord } from 'country-list';
|
||||||
|
import { requestStartGG } from '../api/startgg.js';
|
||||||
|
import type { RecentTournament, ImportedPlayer } from '../../shared/types/domain.js';
|
||||||
|
|
||||||
|
const RECENT_TOURNAMENTS_LIMIT = 12;
|
||||||
|
const PARTICIPANTS_PAGE_SIZE = 120;
|
||||||
|
|
||||||
|
const countries = getData();
|
||||||
|
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
||||||
|
const countryByName = new Map(
|
||||||
|
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
||||||
|
const raw = (country ?? '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
const upper = raw.toUpperCase();
|
||||||
|
if (countryByCode.has(upper)) return upper;
|
||||||
|
return countryByName.get(raw.toLowerCase()) ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchRecentTournaments = async (token: string): Promise<RecentTournament[]> => {
|
||||||
|
const query = `
|
||||||
|
query RecentTournaments($perPage: Int!) {
|
||||||
|
currentUser {
|
||||||
|
tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
startAt
|
||||||
|
endAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await requestStartGG<{
|
||||||
|
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
||||||
|
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
||||||
|
|
||||||
|
return data.currentUser?.tournaments.nodes
|
||||||
|
.filter((item) => item.slug)
|
||||||
|
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||||
|
.map(({ id, name, slug, startAt, endAt }) => ({ id: String(id), name, slug, startAt, endAt })) ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchTournamentPlayers = async (slug: string, token: string): Promise<ImportedPlayer[]> => {
|
||||||
|
const query = `
|
||||||
|
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
||||||
|
tournament(slug: $slug) {
|
||||||
|
participants(query: { page: $page, perPage: $perPage }) {
|
||||||
|
pageInfo {
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
gamerTag
|
||||||
|
prefix
|
||||||
|
user {
|
||||||
|
location {
|
||||||
|
country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
const playersMap = new Map<string, ImportedPlayer>();
|
||||||
|
|
||||||
|
while (currentPage <= totalPages) {
|
||||||
|
const data = await requestStartGG<{
|
||||||
|
tournament: {
|
||||||
|
participants: {
|
||||||
|
pageInfo: { totalPages: number };
|
||||||
|
nodes: Array<{
|
||||||
|
id: number;
|
||||||
|
gamerTag: string | null;
|
||||||
|
prefix: string | null;
|
||||||
|
user: { location: { country: string | null } | null } | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
|
||||||
|
|
||||||
|
if (!data.tournament) throw new Error('Tournament not found');
|
||||||
|
|
||||||
|
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
|
||||||
|
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
|
||||||
|
|
||||||
|
for (const participant of data.tournament.participants.nodes) {
|
||||||
|
const playerId = String(participant.id);
|
||||||
|
const gamertag = (participant.gamerTag ?? '').trim();
|
||||||
|
if (!gamertag) continue;
|
||||||
|
|
||||||
|
playersMap.set(playerId, {
|
||||||
|
id: playerId,
|
||||||
|
gamertag,
|
||||||
|
name: gamertag,
|
||||||
|
team: (participant.prefix ?? '').trim(),
|
||||||
|
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
|
||||||
|
twitter: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(playersMap.values());
|
||||||
|
};
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
import { getData, type CountryRecord } from 'country-list';
|
|
||||||
import { nodecg } from './util/nodecg.js';
|
|
||||||
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
|
||||||
|
|
||||||
// ─── Constantes ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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_TOKEN_ENDPOINTS = [
|
|
||||||
'https://www.start.gg/api/-/rest/oauth/access_token',
|
|
||||||
'https://api.start.gg/oauth/access_token',
|
|
||||||
];
|
|
||||||
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
|
|
||||||
const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
|
|
||||||
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
|
|
||||||
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
const RECENT_TOURNAMENTS_LIMIT = 12;
|
|
||||||
const PARTICIPANTS_PAGE_SIZE = 120;
|
|
||||||
|
|
||||||
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
|
|
||||||
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
|
|
||||||
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
|
|
||||||
//
|
|
||||||
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
|
|
||||||
// (útil para apuntar a un entorno de staging sin recompilar).
|
|
||||||
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
|
||||||
|
|
||||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StartGGGraphQLResponse<T> {
|
|
||||||
data?: T;
|
|
||||||
errors?: Array<{ message?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentTournament {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
startAt: number | null;
|
|
||||||
endAt: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportedPlayer {
|
|
||||||
id: string;
|
|
||||||
gamertag: string;
|
|
||||||
name: string;
|
|
||||||
team: string;
|
|
||||||
country: string;
|
|
||||||
twitter: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OAuthTokenResponse {
|
|
||||||
access_token?: string;
|
|
||||||
error?: string;
|
|
||||||
error_description?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// DEV: cfg/scoreko.json tiene startggClientId + startggClientSecret.
|
|
||||||
// El exchange se hace directamente contra start.gg.
|
|
||||||
//
|
|
||||||
// PROXY: No hay credenciales en la config local.
|
|
||||||
// El clientId se obtiene del Worker (es público, no secreto).
|
|
||||||
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
|
|
||||||
|
|
||||||
type OAuthMode =
|
|
||||||
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
|
||||||
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
|
||||||
|
|
||||||
const getOAuthMode = (): OAuthMode => {
|
|
||||||
const bundleConfig = nodecg.bundleConfig 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;
|
|
||||||
|
|
||||||
// oauthProxyUrl en config permite apuntar a un proxy distinto sin recompilar
|
|
||||||
const proxyBaseUrl =
|
|
||||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
|
||||||
|
|
||||||
if (clientId && clientSecret) {
|
|
||||||
nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)');
|
|
||||||
return { type: 'dev', clientId, clientSecret, callbackPort };
|
|
||||||
}
|
|
||||||
|
|
||||||
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`);
|
|
||||||
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
|
||||||
const rawBody = await response.text();
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
|
||||||
} catch {
|
|
||||||
return { message: rawBody };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Modo dev: exchange directo con start.gg usando credenciales locales */
|
|
||||||
const exchangeCodeDirectly = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
clientId: string,
|
|
||||||
clientSecret: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
|
|
||||||
const exchangeCodeViaProxy = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
proxyBaseUrl: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
const response = await fetch(`${proxyBaseUrl}/oauth/startgg/token`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ code, redirectUri }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawBody = await response.text();
|
|
||||||
let payload: { access_token?: string; error?: string };
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(rawBody) as typeof payload;
|
|
||||||
} catch {
|
|
||||||
payload = { error: rawBody };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
|
||||||
}
|
|
||||||
const token = String(payload.access_token ?? '').trim();
|
|
||||||
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
|
|
||||||
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
|
|
||||||
*/
|
|
||||||
const exchangeOAuthCodeForToken = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
_config: OAuthConfig,
|
|
||||||
): Promise<string> => {
|
|
||||||
const mode = getOAuthMode();
|
|
||||||
if (mode.type === 'dev') {
|
|
||||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
|
||||||
}
|
|
||||||
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── 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, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ query, variables }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: StartGGGraphQLResponse<T>;
|
|
||||||
try {
|
|
||||||
payload = (await response.json()) as StartGGGraphQLResponse<T>;
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid JSON response from start.gg');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.errors?.length) {
|
|
||||||
throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.data) {
|
|
||||||
throw new Error('No data returned by start.gg');
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Resolución de países ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const countries = getData();
|
|
||||||
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
|
||||||
const countryByName = new Map(
|
|
||||||
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
|
||||||
const raw = (country ?? '').trim();
|
|
||||||
if (!raw) return '';
|
|
||||||
const upper = raw.toUpperCase();
|
|
||||||
if (countryByCode.has(upper)) return upper;
|
|
||||||
return countryByName.get(raw.toLowerCase()) ?? '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── 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) => {
|
|
||||||
if (typeof ack === 'function') ack(error, response);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
|
||||||
const mode = getOAuthMode();
|
|
||||||
let serverConfig: OAuthConfig;
|
|
||||||
|
|
||||||
if (mode.type === 'dev') {
|
|
||||||
serverConfig = {
|
|
||||||
clientId: mode.clientId,
|
|
||||||
callbackPort: mode.callbackPort,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Modo proxy: el clientId viene del Worker.
|
|
||||||
// Es público (va en la URL del navegador), pero no lo queremos en el repo.
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`);
|
|
||||||
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
|
||||||
const data = await res.json() as { clientId?: string };
|
|
||||||
const clientId = String(data.clientId ?? '').trim();
|
|
||||||
if (!clientId) throw new Error('Proxy did not return a clientId');
|
|
||||||
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
|
||||||
} catch (err) {
|
|
||||||
sendAck(
|
|
||||||
ack,
|
|
||||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await oauthServer.ensureServer(serverConfig);
|
|
||||||
} catch (err) {
|
|
||||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
|
||||||
});
|
|
||||||
|
|
||||||
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
|
||||||
const sessionId = getStringProp(payload, 'sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
sendAck(ack, 'Missing OAuth session id');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = oauthServer.getSessionStatus(sessionId);
|
|
||||||
if (!status) {
|
|
||||||
sendAck(ack, 'OAuth session not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAck(ack, null, status);
|
|
||||||
});
|
|
||||||
|
|
||||||
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
|
|
||||||
const token = getStringProp(payload, 'token');
|
|
||||||
if (!token) {
|
|
||||||
sendAck(ack, 'Missing start.gg API token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query RecentTournaments($perPage: Int!) {
|
|
||||||
currentUser {
|
|
||||||
tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) {
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
slug
|
|
||||||
startAt
|
|
||||||
endAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await requestStartGG<{
|
|
||||||
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
|
||||||
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
|
||||||
|
|
||||||
const tournaments =
|
|
||||||
data.currentUser?.tournaments.nodes
|
|
||||||
.filter((item) => item.slug)
|
|
||||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
|
||||||
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
|
|
||||||
|
|
||||||
sendAck(ack, null, tournaments);
|
|
||||||
} catch (error) {
|
|
||||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
|
||||||
const token = getStringProp(payload, 'token');
|
|
||||||
const slug = getStringProp(payload, 'slug');
|
|
||||||
|
|
||||||
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
|
|
||||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
|
||||||
tournament(slug: $slug) {
|
|
||||||
participants(query: { page: $page, perPage: $perPage }) {
|
|
||||||
pageInfo {
|
|
||||||
totalPages
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
gamerTag
|
|
||||||
prefix
|
|
||||||
user {
|
|
||||||
location {
|
|
||||||
country
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let currentPage = 1;
|
|
||||||
let totalPages = 1;
|
|
||||||
const playersMap = new Map<string, ImportedPlayer>();
|
|
||||||
|
|
||||||
while (currentPage <= totalPages) {
|
|
||||||
const data = await requestStartGG<{
|
|
||||||
tournament: {
|
|
||||||
participants: {
|
|
||||||
pageInfo: { totalPages: number };
|
|
||||||
nodes: Array<{
|
|
||||||
id: number;
|
|
||||||
gamerTag: string | null;
|
|
||||||
prefix: string | null;
|
|
||||||
user: { location: { country: string | null } | null } | null;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
} | null;
|
|
||||||
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
|
|
||||||
|
|
||||||
if (!data.tournament) throw new Error('Tournament not found');
|
|
||||||
|
|
||||||
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
|
|
||||||
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
|
|
||||||
|
|
||||||
for (const participant of data.tournament.participants.nodes) {
|
|
||||||
const playerId = String(participant.id);
|
|
||||||
const gamertag = (participant.gamerTag ?? '').trim();
|
|
||||||
if (!gamertag) continue;
|
|
||||||
|
|
||||||
playersMap.set(playerId, {
|
|
||||||
id: playerId,
|
|
||||||
gamertag,
|
|
||||||
name: gamertag,
|
|
||||||
team: (participant.prefix ?? '').trim(),
|
|
||||||
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
|
|
||||||
twitter: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPage += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAck(ack, null, Array.from(playersMap.values()));
|
|
||||||
} catch (error) {
|
|
||||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||||
import { resolveCountryCode } from '../../shared/countries';
|
import { resolveCountryCode } from '../../shared/utils/countries';
|
||||||
import { getCharactersByGame } from '../../shared/fighting-characters';
|
import { getCharactersByGame } from '../../shared/fighting-characters';
|
||||||
import type { Schemas } from '../../types';
|
import type { Schemas } from '../../types';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||||
import { resolveCountryCode } from '../../shared/countries';
|
import { resolveCountryCode } from '../../shared/utils/countries';
|
||||||
import type { Schemas } from '../../types';
|
import type { Schemas } from '../../types';
|
||||||
|
|
||||||
useHead({ title: 'Scoreboard' });
|
useHead({ title: 'Scoreboard' });
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface OAuthTokenResponse {
|
||||||
|
access_token?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentTournament {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
startAt: number | null;
|
||||||
|
endAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportedPlayer {
|
||||||
|
id: string;
|
||||||
|
gamertag: string;
|
||||||
|
name: string;
|
||||||
|
team: string;
|
||||||
|
country: string;
|
||||||
|
twitter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OAuthMode =
|
||||||
|
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
||||||
|
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const getStringProp = (payload, key) => {
|
||||||
|
if (typeof payload !== 'object' || payload === null || !(key in payload))
|
||||||
|
return '';
|
||||||
|
const value = payload[key];
|
||||||
|
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
|
};
|
||||||
|
export const getNumberProp = (payload, keys) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
export const normalizeTournamentSlug = (value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
return '';
|
||||||
|
return trimmed
|
||||||
|
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||||
|
.replace(/^tournaments\//i, '')
|
||||||
|
.replace(/^\/+/, '');
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export 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();
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeTournamentSlug = (value: string): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return trimmed
|
||||||
|
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||||
|
.replace(/^tournaments\//i, '')
|
||||||
|
.replace(/^\/+/, '');
|
||||||
|
};
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
"./node_modules/@types"
|
"./node_modules/@types"
|
||||||
],
|
],
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo",
|
||||||
"rootDir": "./src/extension",
|
"rootDir": "./src",
|
||||||
"outDir": "./extension",
|
"outDir": "./",
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Reference in New Issue
Block a user