diff --git a/src/lib/assets/beatsaver-logo_16px.png b/src/lib/assets/beatsaver-logo_16px.png new file mode 100644 index 0000000..64d33c1 Binary files /dev/null and b/src/lib/assets/beatsaver-logo_16px.png differ diff --git a/src/lib/assets/beatsaver-logo_32px.png b/src/lib/assets/beatsaver-logo_32px.png new file mode 100644 index 0000000..58b4153 Binary files /dev/null and b/src/lib/assets/beatsaver-logo_32px.png differ diff --git a/src/lib/assets/beatsaver-logo_512px.png b/src/lib/assets/beatsaver-logo_512px.png new file mode 100644 index 0000000..24dbad6 Binary files /dev/null and b/src/lib/assets/beatsaver-logo_512px.png differ diff --git a/src/lib/components/HasToolAccess.svelte b/src/lib/components/HasToolAccess.svelte new file mode 100644 index 0000000..378acbc --- /dev/null +++ b/src/lib/components/HasToolAccess.svelte @@ -0,0 +1,48 @@ + + +{#if hasAccess} + +{:else} +
+

+ Tools are restricted to BeatLeader supporters (and the top 3k ranked players). +

+ {#if summary} +

{summary}

+ {/if} + {#if showLockedMessage} +

{showLockedMessage}

+ {/if} + {#if showCurrentRank} +

+ {#if playerRankDisplay} + Current global rank: {playerRankDisplay} + {:else} + We couldn't determine your current BeatLeader rank. Refresh after your profile updates. + {/if} +

+ {/if} +
+{/if} + diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 4cc3f86..8efbfb1 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -1,5 +1,6 @@
@@ -104,21 +122,45 @@ {#if checkingSession} Connecting… {:else if user} - - BeatLeader avatar - {user.name ?? 'BeatLeader user'} - +
+ + {#if menuOpen} + + {/if} +
{:else} BeatLeader @@ -142,21 +184,24 @@ {#if checkingSession} Connecting… {:else if user} - - BeatLeader avatar - {user.name ?? 'BeatLeader user'} - +
+ {#if typeof user.rank === 'number' && user.rank > 3000} + + {/if} + {#if dev} + Testing + {/if} + + Profile + BeatLeader + +
+ +
+
{:else} BeatLeader @@ -168,4 +213,113 @@ {/if}
+ + diff --git a/src/lib/server/sessionStore.ts b/src/lib/server/sessionStore.ts new file mode 100644 index 0000000..24e7627 --- /dev/null +++ b/src/lib/server/sessionStore.ts @@ -0,0 +1,125 @@ +import type { Cookies } from '@sveltejs/kit'; +import { dev } from '$app/environment'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const SESSION_COOKIE = 'plebsaber_session'; +const DATA_DIR = '.data'; +const SESSION_FILE = 'plebsaber_sessions.json'; + +export type StoredSession = { + sessionId: string; + beatleaderId: string; + name: string | null; + avatar: string | null; + createdAt: number; + lastSeenAt: number; +}; + +function ensureDataDir(): void { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function sessionFilePath(): string { + return path.join(process.cwd(), DATA_DIR, SESSION_FILE); +} + +function readSessions(): Record { + try { + const file = sessionFilePath(); + if (!fs.existsSync(file)) return {}; + const raw = fs.readFileSync(file, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed as Record; + } + } catch (err) { + console.error('Failed to read session store', err); + } + return {}; +} + +function writeSessions(sessions: Record): void { + try { + ensureDataDir(); + fs.writeFileSync(sessionFilePath(), JSON.stringify(sessions, null, 2), 'utf-8'); + } catch (err) { + console.error('Failed to persist session store', err); + } +} + +function baseCookieOptions() { + return { + path: '/', + httpOnly: true as const, + sameSite: 'lax' as const, + secure: !dev + }; +} + +export function upsertSession( + cookies: Cookies, + input: { beatleaderId: string; name: string | null; avatar: string | null } +): StoredSession { + const sessions = readSessions(); + const existingSessionId = cookies.get(SESSION_COOKIE); + const now = Date.now(); + + if (existingSessionId && sessions[existingSessionId]?.beatleaderId === input.beatleaderId) { + const current = sessions[existingSessionId]; + const updated: StoredSession = { + ...current, + name: input.name, + avatar: input.avatar, + lastSeenAt: now + }; + sessions[existingSessionId] = updated; + writeSessions(sessions); + cookies.set(SESSION_COOKIE, existingSessionId, { ...baseCookieOptions(), maxAge: 10 * 365 * 24 * 3600 }); + return updated; + } + + const sessionId = crypto.randomUUID(); + const session: StoredSession = { + sessionId, + beatleaderId: input.beatleaderId, + name: input.name, + avatar: input.avatar, + createdAt: now, + lastSeenAt: now + }; + + sessions[sessionId] = session; + writeSessions(sessions); + + cookies.set(SESSION_COOKIE, sessionId, { ...baseCookieOptions(), maxAge: 10 * 365 * 24 * 3600 }); + return session; +} + +export function getSession(cookies: Cookies): StoredSession | null { + const sessionId = cookies.get(SESSION_COOKIE); + if (!sessionId) return null; + const sessions = readSessions(); + const session = sessions[sessionId]; + if (!session) return null; + + session.lastSeenAt = Date.now(); + sessions[sessionId] = session; + writeSessions(sessions); + return session; +} + +export function clearSession(cookies: Cookies): void { + const sessionId = cookies.get(SESSION_COOKIE); + if (!sessionId) return; + + const sessions = readSessions(); + if (sessions[sessionId]) { + delete sessions[sessionId]; + writeSessions(sessions); + } + + cookies.delete(SESSION_COOKIE, baseCookieOptions()); +} diff --git a/src/lib/utils/plebsaber-utils.ts b/src/lib/utils/plebsaber-utils.ts index aed958a..527234a 100644 --- a/src/lib/utils/plebsaber-utils.ts +++ b/src/lib/utils/plebsaber-utils.ts @@ -39,6 +39,21 @@ export type BeatLeaderScoresResponse = { metadata?: { page?: number; itemsPerPage?: number; total?: number }; }; +export type BeatLeaderPlayerProfile = { + id?: string; + name?: string; + avatar?: string | null; + country?: string | null; + rank?: number | null; + countryRank?: number | null; +}; + +export type ToolRequirement = { + minGlobalRank?: number; + summary: string; + lockedMessage?: string; +}; + export type Difficulty = { name: string; characteristic: string; @@ -54,6 +69,46 @@ export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] a export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const; +const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = { + minGlobalRank: 3000, + summary: 'BeatLeader global rank within the top 3000', + lockedMessage: 'You must be a BL Patreon supporter or remain ranked in the global top 3k to use this tool.' +}; + +export const TOOL_REQUIREMENTS = { + 'beatleader-compare': DEFAULT_PRIVATE_TOOL_REQUIREMENT, + 'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT, + 'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT +} as const satisfies Record; + +export type ToolKey = keyof typeof TOOL_REQUIREMENTS; + +export function getToolRequirement(key: string): ToolRequirement | null { + return TOOL_REQUIREMENTS[key as ToolKey] ?? null; +} + +export function meetsToolRequirement( + profile: BeatLeaderPlayerProfile | null | undefined, + requirement: ToolRequirement | null | undefined +): boolean { + if (!requirement) return true; + if (requirement.minGlobalRank !== undefined) { + const rank = profile?.rank ?? null; + if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false; + return rank <= requirement.minGlobalRank; + } + return true; +} + +export function formatToolRequirementSummary(requirement: ToolRequirement | null | undefined): string { + if (!requirement) return ''; + if (requirement.summary) return requirement.summary; + if (requirement.minGlobalRank !== undefined) { + return `BeatLeader global rank ≤ ${requirement.minGlobalRank}`; + } + return ''; +} + // ============================================================================ // 3. BeatSaver & BeatLeader API Functions // ============================================================================ diff --git a/src/routes/api/beatleader/me/+server.ts b/src/routes/api/beatleader/me/+server.ts index dbb0585..f455f35 100644 --- a/src/routes/api/beatleader/me/+server.ts +++ b/src/routes/api/beatleader/me/+server.ts @@ -1,64 +1,82 @@ import type { RequestHandler } from '@sveltejs/kit'; -import { createBeatLeaderAPI } from '$lib/server/beatleader'; -import { clearTokens, getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { getSession } from '../../../../lib/server/sessionStore'; -type BeatLeaderIdentity = { id?: string; name?: string }; -type BeatLeaderPlayer = { id?: string; name?: string; avatar?: string | null }; +const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/'; -const IDENTITY_ENDPOINT = 'https://api.beatleader.com/oauth2/identity'; +type BeatLeaderIdentity = { id: string; name: string | null }; +type BeatLeaderPlayer = { + id: string; + name: string | null; + avatar: string | null; + country: string | null; + role: string | null; + rank: number | null; + countryRank: number | null; + techPp: number | null; + accPp: number | null; + passPp: number | null; + pp: number | null; + mapperId: number | null; + level: number | null; + banned: boolean; + profileSettings: { showAllRatings: boolean } | null; +}; + +type ResponsePayload = { + identity: BeatLeaderIdentity; + player: BeatLeaderPlayer | null; + rawPlayer: Record | null; +}; export const GET: RequestHandler = async ({ cookies, fetch }) => { - const token = await getValidAccessToken(cookies); - if (!token) { + const session = getSession(cookies); + if (!session) { return new Response( JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }), { status: 401, headers: { 'content-type': 'application/json' } } ); } - const identityRes = await fetch(IDENTITY_ENDPOINT, { - headers: { Authorization: `Bearer ${token}` } - }); - - if (identityRes.status === 401) { - clearTokens(cookies); - return new Response( - JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }), - { status: 401, headers: { 'content-type': 'application/json' } } - ); - } - - if (!identityRes.ok) { - return new Response( - JSON.stringify({ error: `Identity lookup failed: ${identityRes.status}` }), - { status: 502, headers: { 'content-type': 'application/json' } } - ); - } - - const identityJson = (await identityRes.json()) as BeatLeaderIdentity; const identity: BeatLeaderIdentity = { - id: typeof identityJson.id === 'string' ? identityJson.id : undefined, - name: typeof identityJson.name === 'string' ? identityJson.name : undefined + id: session.beatleaderId, + name: session.name }; - const playerId = identity.id ?? null; let player: BeatLeaderPlayer | null = null; - if (playerId) { - try { - const api = createBeatLeaderAPI(fetch, token, undefined); - const raw = (await api.getPlayer(playerId)) as Record; - const candidate: BeatLeaderPlayer = { - id: typeof raw?.id === 'string' ? (raw.id as string) : playerId, - name: typeof raw?.name === 'string' ? (raw.name as string) : identity.name, - avatar: typeof raw?.avatar === 'string' ? (raw.avatar as string) : null + let rawPlayer: Record | null = null; + + try { + const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`); + if (res.ok) { + rawPlayer = (await res.json()) as Record; + player = { + id: typeof rawPlayer.id === 'string' ? (rawPlayer.id as string) : session.beatleaderId, + name: typeof rawPlayer.name === 'string' ? (rawPlayer.name as string) : session.name, + avatar: typeof rawPlayer.avatar === 'string' ? (rawPlayer.avatar as string) : session.avatar, + country: typeof rawPlayer.country === 'string' ? (rawPlayer.country as string) : null, + role: typeof rawPlayer.role === 'string' ? (rawPlayer.role as string) : null, + rank: typeof rawPlayer.rank === 'number' ? (rawPlayer.rank as number) : null, + countryRank: typeof rawPlayer.countryRank === 'number' ? (rawPlayer.countryRank as number) : null, + techPp: typeof rawPlayer.techPp === 'number' ? (rawPlayer.techPp as number) : null, + accPp: typeof rawPlayer.accPp === 'number' ? (rawPlayer.accPp as number) : null, + passPp: typeof rawPlayer.passPp === 'number' ? (rawPlayer.passPp as number) : null, + pp: typeof rawPlayer.pp === 'number' ? (rawPlayer.pp as number) : null, + mapperId: typeof rawPlayer.mapperId === 'number' ? (rawPlayer.mapperId as number) : null, + level: typeof rawPlayer.level === 'number' ? (rawPlayer.level as number) : null, + banned: Boolean(rawPlayer.banned), + profileSettings: typeof rawPlayer.profileSettings === 'object' && rawPlayer.profileSettings !== null + ? { + showAllRatings: Boolean((rawPlayer.profileSettings as Record).showAllRatings) + } + : null }; - player = candidate; - } catch (err) { - console.error('Failed to fetch BeatLeader player profile', err); } + } catch (err) { + console.error('Failed to refresh BeatLeader public profile', err); } - return new Response(JSON.stringify({ identity, player }), { + const payload: ResponsePayload = { identity, player, rawPlayer }; + return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }); }; diff --git a/src/routes/auth/beatleader/callback/+server.ts b/src/routes/auth/beatleader/callback/+server.ts index 58600e0..7dd467c 100644 --- a/src/routes/auth/beatleader/callback/+server.ts +++ b/src/routes/auth/beatleader/callback/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from '@sveltejs/kit'; -import { consumeAndValidateState, exchangeCodeForTokens, setTokens, clearTokens, consumeRedirectCookie, getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { consumeAndValidateState, exchangeCodeForTokens, clearTokens, consumeRedirectCookie } from '$lib/server/beatleaderAuth'; +import { upsertSession } from '../../../../lib/server/sessionStore'; -export const GET: RequestHandler = async ({ url, cookies }) => { +export const GET: RequestHandler = async ({ url, cookies, fetch }) => { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); @@ -19,19 +20,47 @@ export const GET: RequestHandler = async ({ url, cookies }) => { return new Response('Token exchange failed', { status: 400 }); } - setTokens(cookies, tokenData); - - // Best-effort: enable ShowAllRatings so unranked star ratings are visible for supporters try { - const tok = await getValidAccessToken(cookies); - if (tok) { - const enableUrl = new URL('https://api.beatleader.com/user/profile'); - enableUrl.searchParams.set('showAllRatings', 'true'); - await fetch(enableUrl.toString(), { method: 'PATCH', headers: { Authorization: `Bearer ${tok}` } }); - } - } catch {} + const identityRes = await fetch('https://api.beatleader.com/oauth2/identity', { + headers: { Authorization: `Bearer ${tokenData.access_token}` } + }); + + if (!identityRes.ok) { + clearTokens(cookies); + return new Response('Failed to retrieve identity', { status: 502 }); + } + + const identity = (await identityRes.json()) as { id?: string; name?: string }; + if (!identity?.id) { + clearTokens(cookies); + return new Response('BeatLeader identity missing id', { status: 502 }); + } + + let avatar: string | undefined; + try { + const profileRes = await fetch(`https://api.beatleader.com/player/${identity.id}`); + if (profileRes.ok) { + const profileJson = (await profileRes.json()) as { avatar?: string }; + if (typeof profileJson.avatar === 'string') { + avatar = profileJson.avatar; + } + } + } catch (err) { + console.error('Failed to prefetch BeatLeader avatar', err); + } + + upsertSession(cookies, { + beatleaderId: identity.id, + name: identity.name ?? null, + avatar: avatar ?? null + }); + clearTokens(cookies); + } catch (err) { + console.error('BeatLeader OAuth callback failed', err); + clearTokens(cookies); + return new Response('Internal error establishing session', { status: 500 }); + } - // Redirect back to original target stored before login const redirectTo = consumeRedirectCookie(cookies) ?? url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; diff --git a/src/routes/auth/beatleader/logout-all/+server.ts b/src/routes/auth/beatleader/logout-all/+server.ts index c0e0850..cf70a67 100644 --- a/src/routes/auth/beatleader/logout-all/+server.ts +++ b/src/routes/auth/beatleader/logout-all/+server.ts @@ -1,9 +1,11 @@ import type { RequestHandler } from '@sveltejs/kit'; import { clearTokens, clearBeatLeaderSession } from '$lib/server/beatleaderAuth'; +import { clearSession } from '../../../../lib/server/sessionStore'; export const POST: RequestHandler = async ({ url, cookies }) => { clearTokens(cookies); clearBeatLeaderSession(cookies); + clearSession(cookies); const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; diff --git a/src/routes/auth/beatleader/logout/+server.ts b/src/routes/auth/beatleader/logout/+server.ts index ce8300f..81069f1 100644 --- a/src/routes/auth/beatleader/logout/+server.ts +++ b/src/routes/auth/beatleader/logout/+server.ts @@ -1,14 +1,15 @@ import type { RequestHandler } from '@sveltejs/kit'; import { clearTokens } from '$lib/server/beatleaderAuth'; +import { clearSession } from '../../../../lib/server/sessionStore'; export const GET: RequestHandler = async ({ url }) => { - // For prerendering, redirect to home page const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; export const POST: RequestHandler = async ({ url, cookies }) => { clearTokens(cookies); + clearSession(cookies); const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; diff --git a/src/routes/testing/+page.svelte b/src/routes/testing/+page.svelte new file mode 100644 index 0000000..e4d4eec --- /dev/null +++ b/src/routes/testing/+page.svelte @@ -0,0 +1,242 @@ + + +
+

BeatLeader Testing

+

Debug view for the current BeatLeader OAuth session.

+ + {#if loading} +
Loading player info…
+ {:else if error} +
+ {error} +
+ {:else} +
+
+

Identity

+ {#if identity} +
+
+
ID
+
{identity.id ?? '—'}
+
+
+
Name
+
{identity.name ?? '—'}
+
+
+ {:else} +

No identity data returned.

+ {/if} +
+ +
+

Player

+ {#if player} +
+ Avatar +
+
{player.name ?? 'Unknown'}
+
+ {#if player.country} + {player.country} + {#if player.countryRank !== null} + Rank: {player.countryRank} + {/if} + {/if} + {#if player.rank !== null} + • Global Rank: {player.rank} + {/if} +
+
+
+
+
+
ID
+
{player.id ?? '—'}
+
+
+
Role
+
{player.role ?? '—'}
+
+
+
+
Level
+
{player.level ?? '—'}
+
+
+
PP (Global)
+
{player.pp ?? '—'}
+
+
+
Tech PP
+
{player.techPp ?? '—'}
+
+
+
Acc PP
+
{player.accPp ?? '—'}
+
+
+
Pass PP
+
{player.passPp ?? '—'}
+
+
+
Banned
+
{player.banned ? 'Yes' : 'No'}
+
+
+
Show All Ratings
+
{player.profileSettings?.showAllRatings ? 'Enabled' : 'Disabled'}
+
+
+ {:else} +

No player profile found for this identity.

+ {/if} +
+
+ {/if} +
+ + + diff --git a/src/routes/tools/+layout.server.ts b/src/routes/tools/+layout.server.ts index 2f0550d..38791e2 100644 --- a/src/routes/tools/+layout.server.ts +++ b/src/routes/tools/+layout.server.ts @@ -1,16 +1,37 @@ import { redirect } from '@sveltejs/kit'; -import { getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { getSession } from '../../lib/server/sessionStore'; +import type { BeatLeaderPlayerProfile } from '../../lib/utils/plebsaber-utils'; import type { LayoutServerLoad } from './$types'; +const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/'; + export const prerender = false; -export const load: LayoutServerLoad = async ({ cookies, url }) => { - const token = await getValidAccessToken(cookies); - if (!token) { +export const load: LayoutServerLoad = async ({ cookies, fetch, url }) => { + const session = getSession(cookies); + if (!session) { const pathWithQuery = `${url.pathname}${url.search}` || '/tools'; throw redirect(302, `/auth/beatleader/login?redirect_uri=${encodeURIComponent(pathWithQuery)}`); } - return { hasBeatLeaderOAuth: true }; + let player: BeatLeaderPlayerProfile | null = null; + try { + const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`); + if (res.ok) { + const data = (await res.json()) as Record; + player = { + id: typeof data.id === 'string' ? (data.id as string) : session.beatleaderId, + name: typeof data.name === 'string' ? (data.name as string) : session.name ?? undefined, + avatar: typeof data.avatar === 'string' ? (data.avatar as string) : session.avatar ?? null, + country: typeof data.country === 'string' ? (data.country as string) : null, + rank: typeof data.rank === 'number' ? (data.rank as number) : null, + countryRank: typeof data.countryRank === 'number' ? (data.countryRank as number) : null + }; + } + } catch (err) { + console.error('Failed to fetch BeatLeader profile for tools layout', err); + } + + return { hasBeatLeaderOAuth: true, player }; }; diff --git a/src/routes/tools/beatleader-compare/+page.svelte b/src/routes/tools/beatleader-compare/+page.svelte index f110a4c..bd82aec 100644 --- a/src/routes/tools/beatleader-compare/+page.svelte +++ b/src/routes/tools/beatleader-compare/+page.svelte @@ -1,12 +1,14 @@