new exerimental tools, lessons learned about beatleader authentication (it requires steamworks sdk and steam ticket handling)
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
{#each links as link}
|
||||
<a href={link.href} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" class="btn-neon">Launch Tools</a>
|
||||
<a href="/tools/beatleader-compare" class="btn-neon">Compare Players</a>
|
||||
</nav>
|
||||
|
||||
<button class="md:hidden btn-neon px-3 py-1.5" on:click={toggle} aria-expanded={open} aria-controls="mobile-nav">
|
||||
@@ -39,7 +39,7 @@
|
||||
{#each links as link}
|
||||
<a href={link.href} on:click={close} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" on:click={close} class="btn-neon w-max">Launch Tools</a>
|
||||
<a href="/tools/beatleader-compare" on:click={close} class="btn-neon w-max">Compare Players</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,20 +3,79 @@ const BASE_URL = 'https://api.beatleader.com';
|
||||
// Simple in-memory cache for GET requests to BeatLeader
|
||||
// Caches JSON responses by URL for a short TTL to reduce backend load
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_RETRIES = 5;
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 60_000;
|
||||
const BACKOFF_FACTOR = 2;
|
||||
type CacheEntry = { expiresAt: number; data: unknown };
|
||||
const responseCache: Map<string, CacheEntry> = new Map();
|
||||
const WEBSITE_COOKIE_HEADER = 'Cookie';
|
||||
|
||||
async function fetchJsonCached(fetchFn: typeof fetch, url: string, ttlMs = CACHE_TTL_MS): Promise<unknown> {
|
||||
export class RateLimitError extends Error {
|
||||
readonly status: number = 429;
|
||||
readonly retryAfterMs?: number;
|
||||
constructor(message: string, retryAfterMs?: number) {
|
||||
super(message);
|
||||
this.name = 'RateLimitError';
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWith429Retry(
|
||||
fetchFn: typeof fetch,
|
||||
url: string,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
let attempt = 0;
|
||||
let backoffMs = INITIAL_BACKOFF_MS;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
const res = await fetchFn(url, init);
|
||||
if (res.status === 429) {
|
||||
attempt += 1;
|
||||
const retryAfterHeader = res.headers.get('Retry-After');
|
||||
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
||||
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
|
||||
if (attempt > MAX_RETRIES) {
|
||||
throw new RateLimitError('BeatLeader rate limit exceeded', waitMs);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, waitMs));
|
||||
backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS);
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
attempt += 1;
|
||||
if (attempt > MAX_RETRIES) throw err;
|
||||
await new Promise((r) => setTimeout(r, backoffMs));
|
||||
backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJsonCached(
|
||||
fetchFn: typeof fetch,
|
||||
url: string,
|
||||
options: { headers?: Record<string, string> } = {},
|
||||
ttlMs = CACHE_TTL_MS
|
||||
): Promise<unknown> {
|
||||
const now = Date.now();
|
||||
const cached = responseCache.get(url);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.data;
|
||||
const hasAuth = Boolean(options.headers && (options.headers['Authorization'] || options.headers[WEBSITE_COOKIE_HEADER]));
|
||||
const cacheKey = hasAuth ? null : url;
|
||||
if (cacheKey) {
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetchFn(url);
|
||||
const res = await requestWith429Retry(fetchFn, url, { headers: options.headers });
|
||||
if (!res.ok) throw new Error(`BeatLeader request failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
responseCache.set(url, { expiresAt: now + ttlMs, data });
|
||||
if (cacheKey) {
|
||||
responseCache.set(cacheKey, { expiresAt: now + ttlMs, data });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -34,14 +93,24 @@ function buildQuery(params: QueryParams): string {
|
||||
|
||||
export class BeatLeaderAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly accessToken?: string;
|
||||
private readonly websiteCookieHeader?: string;
|
||||
|
||||
constructor(fetchFn: typeof fetch) {
|
||||
constructor(fetchFn: typeof fetch, accessToken?: string, websiteCookieHeader?: string) {
|
||||
this.fetchFn = fetchFn;
|
||||
this.accessToken = accessToken;
|
||||
this.websiteCookieHeader = websiteCookieHeader;
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> | undefined {
|
||||
if (this.accessToken) return { Authorization: `Bearer ${this.accessToken}` };
|
||||
if (this.websiteCookieHeader) return { [WEBSITE_COOKIE_HEADER]: this.websiteCookieHeader };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getPlayer(playerId: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getPlayerScores(
|
||||
@@ -85,7 +154,7 @@ export class BeatLeaderAPI {
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}/scores${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getLeaderboard(
|
||||
@@ -98,7 +167,7 @@ export class BeatLeaderAPI {
|
||||
const url = `${BASE_URL}/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(
|
||||
diff
|
||||
)}/${encodeURIComponent(mode)}${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getRankedLeaderboards(params: { stars_from?: number; stars_to?: number; page?: number; count?: number } = {}): Promise<unknown> {
|
||||
@@ -110,12 +179,22 @@ export class BeatLeaderAPI {
|
||||
stars_to: params.stars_to
|
||||
});
|
||||
const url = `${BASE_URL}/leaderboards${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getUser(): Promise<unknown> {
|
||||
const url = `${BASE_URL}/user`;
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() }, 30_000);
|
||||
}
|
||||
|
||||
async getLeaderboardsByHash(hash: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/leaderboards/hash/${encodeURIComponent(hash)}`;
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
}
|
||||
|
||||
export function createBeatLeaderAPI(fetchFn: typeof fetch): BeatLeaderAPI {
|
||||
return new BeatLeaderAPI(fetchFn);
|
||||
export function createBeatLeaderAPI(fetchFn: typeof fetch, accessToken?: string, websiteCookieHeader?: string): BeatLeaderAPI {
|
||||
return new BeatLeaderAPI(fetchFn, accessToken, websiteCookieHeader);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const TOKEN_URL = 'https://api.beatleader.com/oauth2/token';
|
||||
|
||||
const ACCESS_COOKIE = 'bl_access_token';
|
||||
const REFRESH_COOKIE = 'bl_refresh_token';
|
||||
const EXPIRES_COOKIE = 'bl_expires_at';
|
||||
const SESSION_COOKIE = 'bl_session_cookie';
|
||||
const STATE_COOKIE = 'bl_oauth_state';
|
||||
const REDIRECT_COOKIE = 'bl_redirect_to';
|
||||
|
||||
// Persistent storage locations
|
||||
const DATA_DIR = '.data';
|
||||
const CREDS_FILE = 'beatleader_oauth.json';
|
||||
const TOKENS_FILE = 'beatleader_tokens.json';
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: '/',
|
||||
httpOnly: true as const,
|
||||
sameSite: 'lax' as const,
|
||||
secure: dev ? false : true
|
||||
};
|
||||
}
|
||||
|
||||
export function createOAuthState(): string {
|
||||
// Use crypto.randomUUID for sufficiently random state
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function setStateCookie(cookies: Cookies, state: string): void {
|
||||
cookies.set(STATE_COOKIE, state, { ...cookieOptions(), maxAge: 600 }); // 10 min
|
||||
}
|
||||
|
||||
export function consumeAndValidateState(cookies: Cookies, received: string | null): boolean {
|
||||
const stored = cookies.get(STATE_COOKIE);
|
||||
cookies.delete(STATE_COOKIE, cookieOptions());
|
||||
return Boolean(stored && received && stored === received);
|
||||
}
|
||||
|
||||
export function setRedirectCookie(cookies: Cookies, redirectTo: string | null | undefined): void {
|
||||
if (!redirectTo) return;
|
||||
try {
|
||||
const url = new URL(redirectTo, 'http://dummy');
|
||||
const value = url.pathname + (url.search || '') + (url.hash || '');
|
||||
cookies.set(REDIRECT_COOKIE, value, { ...cookieOptions(), maxAge: 600 });
|
||||
} catch {
|
||||
// ignore invalid redirect
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeRedirectCookie(cookies: Cookies): string | null {
|
||||
const value = cookies.get(REDIRECT_COOKIE) ?? null;
|
||||
cookies.delete(REDIRECT_COOKIE, cookieOptions());
|
||||
return value;
|
||||
}
|
||||
|
||||
export function setTokens(
|
||||
cookies: Cookies,
|
||||
tokenData: { access_token: string; refresh_token?: string; expires_in?: number }
|
||||
): void {
|
||||
const expiresAt = Date.now() + ((tokenData.expires_in ?? 3600) * 1000);
|
||||
cookies.set(ACCESS_COOKIE, tokenData.access_token, { ...cookieOptions(), maxAge: tokenData.expires_in ?? 3600 });
|
||||
if (tokenData.refresh_token) {
|
||||
// 30 days by default for refresh; adjust as needed
|
||||
cookies.set(REFRESH_COOKIE, tokenData.refresh_token, { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
}
|
||||
cookies.set(EXPIRES_COOKIE, String(expiresAt), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
|
||||
// Persist token data to disk for reuse across restarts (single-user assumption)
|
||||
try {
|
||||
writeJsonPersistent(TOKENS_FILE, {
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token ?? null,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
} catch {
|
||||
// ignore persistence errors
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens(cookies: Cookies): void {
|
||||
cookies.delete(ACCESS_COOKIE, cookieOptions());
|
||||
cookies.delete(REFRESH_COOKIE, cookieOptions());
|
||||
cookies.delete(EXPIRES_COOKIE, cookieOptions());
|
||||
try {
|
||||
deletePersistent(TOKENS_FILE);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist BeatLeader website session cookies captured from upstream Set-Cookie headers.
|
||||
* Only cookie name=value pairs are stored (attributes are discarded). Stored both in httpOnly cookie and on disk.
|
||||
*/
|
||||
export function setBeatLeaderSessionFromSetCookieHeaders(cookies: Cookies, setCookieHeaders: string[] | undefined | null): void {
|
||||
if (!setCookieHeaders || setCookieHeaders.length === 0) return;
|
||||
const nameValuePairs: string[] = [];
|
||||
for (const raw of setCookieHeaders) {
|
||||
const firstSemi = raw.indexOf(';');
|
||||
const pair = firstSemi === -1 ? raw.trim() : raw.slice(0, firstSemi).trim();
|
||||
if (!pair) continue;
|
||||
// Filter out obviously invalid pairs
|
||||
if (!pair.includes('=')) continue;
|
||||
const [name, value] = pair.split('=');
|
||||
if (!name || value === undefined) continue;
|
||||
nameValuePairs.push(`${name}=${value}`);
|
||||
}
|
||||
if (nameValuePairs.length === 0) return;
|
||||
try {
|
||||
cookies.set(SESSION_COOKIE, JSON.stringify(nameValuePairs), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
} catch {}
|
||||
try {
|
||||
writeJsonPersistent('beatleader_session.json', { cookies: nameValuePairs });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Cookie header string ("a=b; c=d") suitable for upstream requests, if a BL session is stored.
|
||||
*/
|
||||
export function getBeatLeaderSessionCookieHeader(cookies: Cookies): string | null {
|
||||
try {
|
||||
const raw = cookies.get(SESSION_COOKIE);
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw) as string[];
|
||||
if (Array.isArray(arr) && arr.length > 0) return arr.join('; ');
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const persisted = readJsonPersistent<{ cookies?: string[] }>('beatleader_session.json');
|
||||
if (persisted?.cookies && persisted.cookies.length > 0) return persisted.cookies.join('; ');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearBeatLeaderSession(cookies: Cookies): void {
|
||||
cookies.delete(SESSION_COOKIE, cookieOptions());
|
||||
try {
|
||||
deletePersistent('beatleader_session.json');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getAccessTokenFromCookies(cookies: Cookies): { token: string | null; expiresAt: number | null } {
|
||||
const token = cookies.get(ACCESS_COOKIE) ?? null;
|
||||
const expiresRaw = cookies.get(EXPIRES_COOKIE);
|
||||
const expiresAt = expiresRaw ? Number(expiresRaw) : null;
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(cookies: Cookies): Promise<string | null> {
|
||||
const refreshToken = cookies.get(REFRESH_COOKIE);
|
||||
let tokenToUse: string | undefined = refreshToken ?? undefined;
|
||||
if (!tokenToUse) {
|
||||
// Try persistent token store
|
||||
const persisted = readJsonPersistent<{ refresh_token?: string | null }>(TOKENS_FILE);
|
||||
tokenToUse = (persisted?.refresh_token ?? undefined) as string | undefined;
|
||||
}
|
||||
if (!tokenToUse) return null;
|
||||
|
||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
||||
const clientId = creds?.client_id;
|
||||
const clientSecret = creds?.client_secret;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: tokenToUse
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
clearTokens(cookies);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await res.json();
|
||||
setTokens(cookies, tokenData);
|
||||
return tokenData.access_token as string;
|
||||
}
|
||||
|
||||
export async function getValidAccessToken(cookies: Cookies): Promise<string | null> {
|
||||
const { token, expiresAt } = getAccessTokenFromCookies(cookies);
|
||||
if (token && expiresAt && Date.now() < expiresAt - 10_000) {
|
||||
return token; // still valid (with 10s skew)
|
||||
}
|
||||
// Try refresh
|
||||
const refreshed = await refreshAccessToken(cookies);
|
||||
if (refreshed) return refreshed;
|
||||
|
||||
// As last resort, if we have a persisted access token that may still be valid, use it
|
||||
const persisted = readJsonPersistent<{ access_token?: string | null; expires_at?: number | null }>(TOKENS_FILE);
|
||||
if (persisted?.access_token && persisted?.expires_at && Date.now() < (persisted.expires_at - 10_000)) {
|
||||
cookies.set(ACCESS_COOKIE, persisted.access_token, { ...cookieOptions(), maxAge: Math.floor((persisted.expires_at - Date.now()) / 1000) });
|
||||
cookies.set(EXPIRES_COOKIE, String(persisted.expires_at), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
return persisted.access_token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl(origin: string, scopes: string[]): URL {
|
||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
||||
const clientId = creds?.client_id;
|
||||
if (!clientId) throw new Error('BeatLeader OAuth is not configured. Visit /tools/beatleader-oauth to set it up.');
|
||||
const redirectUri = `${origin}/auth/beatleader/callback`;
|
||||
const url = new URL('https://api.beatleader.com/oauth2/authorize');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
// BeatLeader expects scopes without the "scp:" prefix in the authorization request
|
||||
const requestedScopes = scopes.map(s => s.replace(/^scp:/, '')).join(' ');
|
||||
url.searchParams.set('scope', requestedScopes);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(origin: string, code: string): Promise<{ access_token: string; refresh_token?: string; expires_in?: number } | null> {
|
||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
||||
const clientId = creds?.client_id;
|
||||
const clientSecret = creds?.client_secret;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
const redirectUri = `${origin}/auth/beatleader/callback`;
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as any;
|
||||
}
|
||||
|
||||
// Persistent storage helpers (single-user local setup)
|
||||
function ensureDataDir(): void {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(name: string): string {
|
||||
return path.join(process.cwd(), DATA_DIR, name);
|
||||
}
|
||||
|
||||
function writeJsonPersistent<T extends object>(name: string, data: T): void {
|
||||
ensureDataDir();
|
||||
fs.writeFileSync(resolvePath(name), JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function readJsonPersistent<T>(name: string): T | null {
|
||||
try {
|
||||
const p = resolvePath(name);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = fs.readFileSync(p, 'utf-8');
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePersistent(name: string): void {
|
||||
try {
|
||||
const p = resolvePath(name);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function storeOAuthCredentials(input: { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] }): void {
|
||||
writeJsonPersistent(CREDS_FILE, input);
|
||||
}
|
||||
|
||||
export function readOAuthCredentials(): { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] } | null {
|
||||
return readJsonPersistent(CREDS_FILE);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CuratedSongInfo {
|
||||
hash: string;
|
||||
key: string;
|
||||
songName: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface MapperMapInfo extends CuratedSongInfo {
|
||||
@@ -57,7 +58,10 @@ export class BeatSaverAPI {
|
||||
}
|
||||
|
||||
// Public API
|
||||
async getCuratedSongs(useCache: boolean = true): Promise<CuratedSongInfo[]> {
|
||||
async getCuratedSongs(
|
||||
useCache: boolean = true,
|
||||
options: { maxPages?: number; tolerateErrors?: boolean } = {}
|
||||
): Promise<CuratedSongInfo[]> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, 'curated_songs.json');
|
||||
if (useCache) {
|
||||
const cached = await this.readCache<CuratedSongInfo[]>(cachePath);
|
||||
@@ -66,11 +70,15 @@ export class BeatSaverAPI {
|
||||
|
||||
const processed: CuratedSongInfo[] = [];
|
||||
let page = 0;
|
||||
const maxPages = options.maxPages ?? undefined;
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ sortOrder: 'Curated', curated: 'true' })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
if (options.tolerateErrors) break;
|
||||
throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
|
||||
}
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
@@ -78,7 +86,8 @@ export class BeatSaverAPI {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName
|
||||
songName: song?.metadata?.songName,
|
||||
date: song?.lastPublishedAt ?? song?.uploaded ?? song?.createdAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -86,6 +95,7 @@ export class BeatSaverAPI {
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
if (maxPages !== undefined && page >= maxPages) break;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
@@ -296,7 +306,7 @@ export class BeatSaverAPI {
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
return fs.promises as typeof import('fs').promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
@@ -253,7 +253,7 @@ export class PlaylistBuilder {
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
return fs.promises as typeof import('fs').promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
@@ -103,8 +103,8 @@ export class ScoreSaberAPI {
|
||||
const entries = await fs.readdir(this.cacheDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => fs.unlink(this.pathJoin(this.cacheDir, e.name)))
|
||||
.filter((entry: import('fs').Dirent) => entry.isFile())
|
||||
.map((entry: import('fs').Dirent) => fs.unlink(this.pathJoin(this.cacheDir, entry.name)))
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -216,7 +216,7 @@ export class ScoreSaberAPI {
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
return fs.promises as typeof import('fs').promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
const OPENID_ENDPOINT = 'https://steamcommunity.com/openid/login';
|
||||
|
||||
const STEAM_ID_COOKIE = 'steam_id64';
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: '/',
|
||||
httpOnly: true as const,
|
||||
sameSite: 'lax' as const,
|
||||
secure: dev ? false : true,
|
||||
maxAge: 30 * 24 * 3600
|
||||
};
|
||||
}
|
||||
|
||||
export function setSteamIdCookie(cookies: Cookies, steamId64: string): void {
|
||||
cookies.set(STEAM_ID_COOKIE, steamId64, cookieOptions());
|
||||
}
|
||||
|
||||
export function getSteamIdFromCookies(cookies: Cookies): string | null {
|
||||
return cookies.get(STEAM_ID_COOKIE) ?? null;
|
||||
}
|
||||
|
||||
export function clearSteamCookie(cookies: Cookies): void {
|
||||
cookies.delete(STEAM_ID_COOKIE, cookieOptions());
|
||||
}
|
||||
|
||||
export function buildSteamOpenIDLoginUrl(origin: string, redirectTo?: string | null): URL {
|
||||
const returnTo = new URL('/auth/steam/callback', origin);
|
||||
if (redirectTo) returnTo.searchParams.set('redirect_uri', redirectTo);
|
||||
|
||||
const realm = origin;
|
||||
|
||||
const url = new URL(OPENID_ENDPOINT);
|
||||
url.searchParams.set('openid.ns', 'http://specs.openid.net/auth/2.0');
|
||||
url.searchParams.set('openid.mode', 'checkid_setup');
|
||||
url.searchParams.set('openid.return_to', returnTo.toString());
|
||||
url.searchParams.set('openid.realm', realm);
|
||||
url.searchParams.set('openid.identity', 'http://specs.openid.net/auth/2.0/identifier_select');
|
||||
url.searchParams.set('openid.claimed_id', 'http://specs.openid.net/auth/2.0/identifier_select');
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function verifySteamOpenIDResponse(searchParams: URLSearchParams): Promise<string | null> {
|
||||
// Steam expects the exact parameters back with mode switched to check_authentication
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (key === 'openid.mode') continue;
|
||||
if (!key.startsWith('openid.')) continue;
|
||||
params.set(key, value);
|
||||
}
|
||||
params.set('openid.mode', 'check_authentication');
|
||||
|
||||
const res = await fetch(OPENID_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
if (!/is_valid\s*:\s*true/.test(text)) return null;
|
||||
|
||||
const claimed = searchParams.get('openid.claimed_id') || '';
|
||||
// Expected format: https://steamcommunity.com/openid/id/7656119...
|
||||
const m = claimed.match(/\/id\/(\d+)$/);
|
||||
if (!m) return null;
|
||||
return m[1];
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@ function handleTimeUpdate(): void {
|
||||
}
|
||||
|
||||
function handleMetadata(): void {
|
||||
if (!audio) return;
|
||||
update((state) => ({ ...state, duration: isFinite(audio.duration) ? audio.duration : 0 }));
|
||||
const a = audio;
|
||||
if (!a) return;
|
||||
const dur = Number.isFinite(a.duration) ? a.duration : 0;
|
||||
update((state) => ({ ...state, duration: dur }));
|
||||
}
|
||||
|
||||
function handleEnded(): void {
|
||||
|
||||
Reference in New Issue
Block a user