diff --git a/README.md b/README.md index 75842c4..1d83f9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# sv +# Plebsaber.stream -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +BeatLeader‑powered tools for Beat Saber. ## Creating a project @@ -14,6 +14,17 @@ npx sv create npx sv create my-app ``` +## BeatLeader OAuth setup + +Set these environment variables (e.g. in your process manager or `.env` when using adapter‑node): + +``` +BL_CLIENT_ID=your_client_id +BL_CLIENT_SECRET=your_client_secret +``` + +Then start the dev server and visit `/auth/beatleader/login` to authenticate. Tokens are stored in httpOnly cookies and attached to all server‑side BeatLeader API calls. + ## Developing Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: diff --git a/assets/voting-tracker.png b/assets/voting-tracker.png new file mode 100644 index 0000000..34ac209 Binary files /dev/null and b/assets/voting-tracker.png differ diff --git a/docs/decisions/frameworks.md b/docs/decisions/frameworks.md new file mode 100644 index 0000000..5ab78d0 --- /dev/null +++ b/docs/decisions/frameworks.md @@ -0,0 +1,138 @@ +# Architecture Decision Record: Use Tauri with Embedded Node for Cross-Platform App + +**Date:** 2025-08-15 +**Status:** Accepted +**Decision Makers:** \[Team/Project Name] +**Context:** Desktop + Mobile App Development + +--- + +## **Context** + +We are building a cross-platform application targeting **desktop (Windows, macOS, Linux)** and **mobile (Android, iOS)** using **SvelteKit + TailwindCSS** for the UI. + +Key goals: + +* Maintain **fast development iteration** with hot reload for both frontend and backend logic. +* Allow backend logic to be written in **TypeScript** for developer familiarity and speed. +* Support writing **performance-critical features in Rust** if needed. +* Deploy the **same codebase** across desktop and mobile with minimal divergence. +* Avoid security limitations of browser-only apps (e.g., CORS, sandbox restrictions). +* Provide direct access to **Node APIs** and/or **Rust commands** for native capabilities. + +--- + +## **Decision** + +We will use **Tauri v2** with an **embedded Node runtime** alongside the SvelteKit frontend. + +```mermaid +graph LR +%% "Tauri + Node + SvelteKit + Tailwind Architecture" + +subgraph "Frontend (UI)" + SvelteKit["SvelteKit
(TS + TailwindCSS)"] + WebView["WebView
(Tauri Runtime)"] + SvelteKit --> WebView +end + +subgraph "Backend Logic" + NodeJS["Embedded Node.js Runtime
(TypeScript backend)"] + Rust["Rust Commands
(Tauri APIs / Native modules)"] +end + +subgraph "Platforms" + Desktop["Desktop
(Windows, macOS, Linux)"] + Mobile["Mobile
(Android, iOS)"] +end + +WebView -->|IPC / API calls| NodeJS +WebView -->|Tauri Commands| Rust +NodeJS -->|FFI / API bridge| Rust +Rust -->|Native APIs| Desktop +Rust -->|Native APIs| Mobile +WebView --> Desktop +WebView --> Mobile +``` + +**Why Tauri**: + +* Small runtime compared to Electron for desktop builds (\~5–20 MB without Node). +* Native integration with OS features (system tray, notifications, file access, etc.). +* Built-in support for **mobile (Android/iOS)** in Tauri v2. +* Strong security model and long-term community backing. + +**Why embed Node**: + +* Node.js runtime allows full access to existing npm ecosystem and built-in modules (`fs`, `crypto`, `net`, etc.). +* Enables backend logic to be written entirely in TypeScript during early development. +* Hot reload with tools like `nodemon` or `ts-node` for fast iteration. +* Flexibility to migrate specific logic to Rust incrementally without losing productivity. + +**Workflow**: + +* **Development**: + + * Run SvelteKit dev server for UI (`npm run dev`) + * Run Node backend with hot reload + * Tauri shell points to `localhost` dev server for instant updates +* **Production**: + + * Bundle SvelteKit output with `adapter-static` or `adapter-node` + * Include Node runtime for backend logic + * Optionally replace parts of backend with compiled Rust commands for performance-critical paths + +--- + +## **Alternatives Considered** + +1. **Capacitor** + + * ✅ Mature mobile ecosystem and simpler dev workflow for mobile + * ❌ No built-in Node runtime; would require rewriting backend logic as plugins or browser-safe code + * ❌ We would lose direct access to Node APIs without extra complexity + +2. **Electron** + + * ✅ First-class Node integration + * ❌ Larger desktop build size (50–150 MB) + * ❌ Heavier memory footprint compared to Tauri + +3. **Pure Tauri (Rust backend only)** + + * ✅ Smallest build sizes, fastest runtime + * ❌ Slower development for backend logic without TS hot reload + * ❌ Would require Rust expertise for all backend features from day one + +--- + +## **Consequences** + +**Positive:** + +* Full-stack TypeScript possible in early development → faster MVP delivery. +* Incremental migration path to Rust for performance-sensitive features. +* Single codebase for desktop & mobile. +* Rich native API access via both Node and Tauri’s Rust plugins. +* Security benefits from Tauri’s isolation model. + +**Negative:** + +* Larger mobile builds (\~25–40 MB vs. Capacitor’s \~10–20 MB). +* More complex tooling setup (Rust + Node + mobile SDKs). +* Slightly slower startup time on mobile due to Node runtime initialization. + +--- + +## **References** + +* [Tauri v2 Documentation](https://v2.tauri.app/) +* [Node.js Documentation](https://nodejs.org/en/docs) +* [SvelteKit Documentation](https://kit.svelte.dev/) +* [TailwindCSS Documentation](https://tailwindcss.com/) + +--- + +I can also add a **diagram showing the architecture** — frontend, embedded Node backend, and optional Rust modules — so stakeholders can visually understand how the stack fits together across platforms. + +Do you want me to make that diagram next? diff --git a/package-lock.json b/package-lock.json index 4066643..4ab5c44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24.2.1", "@vitest/browser": "^3.2.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -1752,6 +1753,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", + "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4826,6 +4837,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 150b1a3..0e53fef 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24.2.1", "@vitest/browser": "^3.2.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/plebsaber.stream.code-workspace b/plebsaber.stream.code-workspace new file mode 100644 index 0000000..07f11b9 --- /dev/null +++ b/plebsaber.stream.code-workspace @@ -0,0 +1,17 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../../../src/beatleader-website" + }, + { + "path": "../../../src/beatleader-server" + }, + { + "path": "../../../src/beatleader-mod" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..8d11e7c 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,7 +3,9 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + blAccessToken?: string | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 0cde6fd..62a0b2e 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -24,7 +24,7 @@ {#each links as link} {link.label} {/each} - Launch Tools + Compare Players - - - {/each} +
+
+

Latest Guides

+
+
+ +
Guide
+

Finding New Songs (BeatLeader)

+

Month-by-month search using unranked stars, tech rating, and friend filters.

+
+ +
Guide
+

BeatLeader Authentication

+

Connect BeatLeader and enable unranked stars in tools.

+
+ + diff --git a/src/routes/api/beatleader/+server.ts b/src/routes/api/beatleader/+server.ts index bb6ced1..07137ef 100644 --- a/src/routes/api/beatleader/+server.ts +++ b/src/routes/api/beatleader/+server.ts @@ -1,8 +1,84 @@ import type { RequestHandler } from '@sveltejs/kit'; -import { createBeatLeaderAPI } from '$lib/server/beatleader'; +import { createBeatLeaderAPI, RateLimitError } from '$lib/server/beatleader'; +import { getValidAccessToken, getBeatLeaderSessionCookieHeader } from '$lib/server/beatleaderAuth'; +import { getSteamIdFromCookies } from '$lib/server/steam'; -export const GET: RequestHandler = async ({ fetch, url }) => { - const api = createBeatLeaderAPI(fetch); +export const GET: RequestHandler = async ({ fetch, url, cookies }) => { + type AuthMode = 'steam' | 'oauth' | 'session' | 'auto' | 'none'; + const authMode = (url.searchParams.get('auth') as AuthMode | null) ?? 'steam'; + const debug = url.searchParams.get('debug') === '1'; + + let token: string | undefined; + let websiteCookieHeader: string | undefined; + + if (authMode === 'oauth') { + const maybeToken = await getValidAccessToken(cookies); + token = maybeToken ?? undefined; + } else if (authMode === 'session') { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } else if (authMode === 'steam') { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!websiteCookieHeader) { + const steamId = getSteamIdFromCookies(cookies); + if (steamId) { + try { + const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials }); + const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? []; + const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth'); + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } catch {} + } + } + } else if (authMode === 'auto') { + const maybeToken = await getValidAccessToken(cookies); + if (maybeToken) { + token = maybeToken; + } else { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!websiteCookieHeader) { + const steamId = getSteamIdFromCookies(cookies); + if (steamId) { + try { + const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials }); + const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? []; + const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth'); + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } catch {} + } + } + } + } else { + // 'none' -> no auth headers + } + + // Wrap fetch to track upstream BL requests + let blCallCount = 0; + let blLastUrl: string | undefined; + let blLastHeaders: Record | undefined; + const wrappedFetch: typeof fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const inputUrl = typeof input === 'string' ? input : (input instanceof URL ? input.toString() : (input as Request).url); + const isBL = String(inputUrl).startsWith('https://api.beatleader.com'); + if (isBL) { + blCallCount += 1; + blLastUrl = String(inputUrl); + const hdrs: Record = {}; + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((v, k) => { hdrs[k] = v; }); + } else if (Array.isArray(init.headers)) { + for (const [k, v] of init.headers) { if (v != null) hdrs[k] = String(v); } + } else { + Object.entries(init.headers as Record).forEach(([k, v]) => { if (v != null) hdrs[k] = String(v); }); + } + } + blLastHeaders = hdrs; + } + return fetch(input as any, init as any); + }; + const api = createBeatLeaderAPI(wrappedFetch, token, websiteCookieHeader); + const resolvedAuth = token ? 'oauth' : (websiteCookieHeader ? 'session' : 'none'); const path = url.searchParams.get('path'); if (!path) { return new Response(JSON.stringify({ error: 'Missing path' }), { status: 400 }); @@ -15,12 +91,58 @@ export const GET: RequestHandler = async ({ fetch, url }) => { if (parts.length === 2) { const playerId = parts[1]; const data = await api.getPlayer(playerId); - return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); } if (parts.length === 3 && parts[2] === 'scores') { const playerId = parts[1]; const data = await api.getPlayerScores(playerId, Object.fromEntries(url.searchParams)); - return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); } } @@ -41,6 +163,155 @@ export const GET: RequestHandler = async ({ fetch, url }) => { } } + if (path.startsWith('/leaderboards/hash/')) { + const parts = path.split('/').filter(Boolean); + if (parts.length === 3) { + const hash = parts[2]; + const data = await api.getLeaderboardsByHash(hash); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); + } + } + + if (path === '/user') { + const redirect = url.searchParams.get('redirect_uri') ?? '/'; + if (authMode === 'oauth') { + if (!token) { + return new Response( + JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login?redirect_uri=' + encodeURIComponent(redirect) }), + { status: 401, headers: { 'content-type': 'application/json' } } + ); + } + const data = await api.getUser(); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); + } + if (authMode === 'session') { + const cookieHeader = websiteCookieHeader ?? getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!cookieHeader) { + return new Response( + JSON.stringify({ error: 'Unauthorized', sessionLogin: '/tools/beatleader-session?return=' + encodeURIComponent(redirect) }), + { status: 401, headers: { 'content-type': 'application/json' } } + ); + } + const res = await fetch('https://api.beatleader.com/user', { headers: { Cookie: cookieHeader } }); + const data = await res.json(); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + headers['x-bl-last-url'] = 'https://api.beatleader.com/user'; + headers['x-bl-last-cookie'] = debug ? String(cookieHeader) : 'present'; + headers['x-bl-last-authorization'] = 'absent'; + const curlParts = [ 'curl', '-sS', '-X', 'GET', 'https://api.beatleader.com/user' ]; + if (cookieHeader) curlParts.push('-H', debug ? `\"Cookie: ${cookieHeader}\"` : '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + return new Response(JSON.stringify(data), { headers }); + } + if (authMode === 'steam') { + const cookieHeader = websiteCookieHeader ?? getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!cookieHeader) { + return new Response( + JSON.stringify({ error: 'Unauthorized', steamLogin: '/auth/steam/login?redirect_uri=' + encodeURIComponent(redirect) }), + { status: 401, headers: { 'content-type': 'application/json' } } + ); + } + const res = await fetch('https://api.beatleader.com/user', { headers: { Cookie: cookieHeader } }); + const data = await res.json(); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + headers['x-bl-last-url'] = 'https://api.beatleader.com/user'; + headers['x-bl-last-cookie'] = debug ? String(cookieHeader) : 'present'; + headers['x-bl-last-authorization'] = 'absent'; + const curlParts = [ 'curl', '-sS', '-X', 'GET', 'https://api.beatleader.com/user' ]; + if (cookieHeader) curlParts.push('-H', debug ? `\"Cookie: ${cookieHeader}\"` : '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + return new Response(JSON.stringify(data), { headers }); + } + // auto/none fallthrough + if (token) { + const data = await api.getUser(); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); + } + const cookieHeader = websiteCookieHeader ?? getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (cookieHeader) { + const res = await fetch('https://api.beatleader.com/user', { headers: { Cookie: cookieHeader } }); + const data = await res.json(); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + headers['x-bl-last-url'] = 'https://api.beatleader.com/user'; + headers['x-bl-last-cookie'] = debug ? String(cookieHeader) : 'present'; + headers['x-bl-last-authorization'] = 'absent'; + const curlParts = [ 'curl', '-sS', '-X', 'GET', 'https://api.beatleader.com/user' ]; + if (cookieHeader) curlParts.push('-H', debug ? `\"Cookie: ${cookieHeader}\"` : '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + return new Response(JSON.stringify(data), { headers }); + } + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'content-type': 'application/json' } }); + } + if (path === '/leaderboards') { const data = await api.getRankedLeaderboards({ stars_from: url.searchParams.get('stars_from') ? Number(url.searchParams.get('stars_from')) : undefined, @@ -48,13 +319,46 @@ export const GET: RequestHandler = async ({ fetch, url }) => { page: url.searchParams.get('page') ? Number(url.searchParams.get('page')) : undefined, count: url.searchParams.get('count') ? Number(url.searchParams.get('count')) : undefined }); - return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); } return new Response(JSON.stringify({ error: 'Unsupported path' }), { status: 400 }); } catch (err) { + if (err instanceof RateLimitError) { + const retrySeconds = err.retryAfterMs ? Math.ceil(err.retryAfterMs / 1000) : undefined; + const headers: Record = { 'content-type': 'application/json' }; + if (retrySeconds !== undefined && Number.isFinite(retrySeconds)) { + headers['Retry-After'] = String(retrySeconds); + } + return new Response(JSON.stringify({ error: 'Rate limited by BeatLeader' }), { status: 429, headers }); + } const message = err instanceof Error ? err.message : 'Unknown error'; - return new Response(JSON.stringify({ error: message }), { status: 502 }); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + return new Response(JSON.stringify({ error: message }), { status: 502, headers }); } }; diff --git a/src/routes/api/beatleader/oauth/creds/+server.ts b/src/routes/api/beatleader/oauth/creds/+server.ts new file mode 100644 index 0000000..4598676 --- /dev/null +++ b/src/routes/api/beatleader/oauth/creds/+server.ts @@ -0,0 +1,40 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { readOAuthCredentials, storeOAuthCredentials } from '$lib/server/beatleaderAuth'; + +export const GET: RequestHandler = async () => { + const creds = readOAuthCredentials(); + if (!creds) return new Response('{}', { headers: { 'content-type': 'application/json' } }); + return new Response(JSON.stringify({ client_id: creds.client_id, client_secret: creds.client_secret }), { + headers: { 'content-type': 'application/json' } + }); +}; + +export const POST: RequestHandler = async ({ request }) => { + try { + const contentType = request.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const body = (await request.json()) as any; + const client_id = String(body.client_id || '').trim(); + const client_secret = String(body.client_secret || '').trim(); + if (!client_id || !client_secret) { + return new Response('client_id and client_secret are required', { status: 400 }); + } + storeOAuthCredentials({ client_id, client_secret }); + return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }); + } + + const form = await request.formData(); + const client_id = String(form.get('client_id') || '').trim(); + const client_secret = String(form.get('client_secret') || '').trim(); + if (!client_id || !client_secret) { + return new Response('client_id and client_secret are required', { status: 400 }); + } + storeOAuthCredentials({ client_id, client_secret }); + return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save credentials'; + return new Response(message, { status: 500 }); + } +}; + + diff --git a/src/routes/api/beatleader/oauth/register/+server.ts b/src/routes/api/beatleader/oauth/register/+server.ts new file mode 100644 index 0000000..6d51720 --- /dev/null +++ b/src/routes/api/beatleader/oauth/register/+server.ts @@ -0,0 +1,78 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { storeOAuthCredentials } from '$lib/server/beatleaderAuth'; + +// Registers a BeatLeader OAuth app using the user's BL session cookie (must be logged in on beatleader.com) +// Validates icon and relays to BeatLeader API, then persists returned client credentials. +export const POST: RequestHandler = async ({ request, fetch, url }) => { + const form = await request.formData(); + const name = String(form.get('name') ?? ''); + const clientId = String(form.get('clientId') ?? ''); + const scopes = String(form.get('scopes') ?? 'scp:profile'); + const redirectUrls = String(form.get('redirectUrls') ?? ''); + const icon = form.get('icon'); + + if (!name || name.length < 2 || name.length > 25) { + return new Response('Invalid name', { status: 400 }); + } + if (clientId && clientId.length < 4) { + return new Response('Client ID must be at least 4 characters long', { status: 400 }); + } + if (!redirectUrls) { + return new Response('Redirect URLs required', { status: 400 }); + } + + // Validate icon + if (icon && icon instanceof File) { + const valid = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']; + if (!valid.includes(icon.type)) return new Response('Icon must be JPG/PNG/GIF', { status: 400 }); + if (icon.size > 5 * 1024 * 1024) return new Response('Icon must be under 5MB', { status: 400 }); + } + + const params = new URLSearchParams({ + name, + clientId: clientId || generateClientId(), + scopes, + redirectUrls + }); + const endpoint = new URL(`https://api.beatleader.com/developer/app?${params.toString()}`); + + // Forward request including cookies (BeatLeader requires login) + let blRes: Response; + if (icon && icon instanceof File) { + const fd = new FormData(); + fd.set('icon', icon); + blRes = await fetch(endpoint.toString(), { method: 'POST', body: fd, credentials: 'include' as RequestCredentials }); + } else { + blRes = await fetch(endpoint.toString(), { method: 'POST', credentials: 'include' as RequestCredentials }); + } + + if (!blRes.ok) { + const text = await blRes.text(); + const headers: Record = {}; + const retryAfter = blRes.headers.get('Retry-After'); + if (blRes.status === 429 && retryAfter) headers['Retry-After'] = retryAfter; + return new Response(text || 'BeatLeader registration failed', { status: blRes.status, headers }); + } + + const created = (await blRes.json()) as any; + const out = { + clientId: created?.clientId ?? created?.client_id, + clientSecret: created?.clientSecret ?? created?.client_secret, + name: created?.name, + scopes: created?.scopes ?? scopes.split(',').map((s) => s.trim()), + redirectUrls: created?.redirectUrls ?? redirectUrls.split(',').map((s) => s.trim()) + }; + + if (!out.clientId || !out.clientSecret) { + return new Response('BeatLeader response missing credentials', { status: 502 }); + } + + storeOAuthCredentials({ client_id: out.clientId, client_secret: out.clientSecret, scopes: out.scopes, redirect_urls: out.redirectUrls }); + return new Response(JSON.stringify(out), { headers: { 'content-type': 'application/json' } }); +}; + +function generateClientId(): string { + return 'bl_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); +} + + diff --git a/src/routes/api/beatleader/oauth/status/+server.ts b/src/routes/api/beatleader/oauth/status/+server.ts new file mode 100644 index 0000000..9ba4c69 --- /dev/null +++ b/src/routes/api/beatleader/oauth/status/+server.ts @@ -0,0 +1,24 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { getValidAccessToken, readOAuthCredentials, getBeatLeaderSessionCookieHeader } from '$lib/server/beatleaderAuth'; +import { getSteamIdFromCookies } from '$lib/server/steam'; + +export const GET: RequestHandler = async ({ cookies }) => { + try { + const token = await getValidAccessToken(cookies); + const creds = readOAuthCredentials(); + const steamId = getSteamIdFromCookies(cookies); + const payload = { + connected: Boolean(token), + hasCreds: Boolean(creds && creds.client_id && creds.client_secret), + hasSession: Boolean(getBeatLeaderSessionCookieHeader(cookies)), + hasSteam: Boolean(steamId), + steamId: steamId ?? null + }; + return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }); + } catch (err) { + const payload = { connected: false, hasCreds: false, hasSession: false, hasSteam: false, steamId: null }; + return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }); + } +}; + + diff --git a/src/routes/api/beatleader/player/[id]/+server.ts b/src/routes/api/beatleader/player/[id]/+server.ts index 39394a3..f75418e 100644 --- a/src/routes/api/beatleader/player/[id]/+server.ts +++ b/src/routes/api/beatleader/player/[id]/+server.ts @@ -1,21 +1,146 @@ import type { RequestHandler } from '@sveltejs/kit'; import { createBeatLeaderAPI } from '$lib/server/beatleader'; +import { getBeatLeaderSessionCookieHeader, getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { getSteamIdFromCookies } from '$lib/server/steam'; -export const GET: RequestHandler = async ({ fetch, params, url }) => { - const api = createBeatLeaderAPI(fetch); +export const GET: RequestHandler<{ id: string }> = async ({ fetch, params, url, cookies }) => { + type AuthMode = 'steam' | 'oauth' | 'session' | 'auto' | 'none'; + const authMode = (url.searchParams.get('auth') as AuthMode | null) ?? 'steam'; + const debug = url.searchParams.get('debug') === '1'; + + let token: string | undefined; + let websiteCookieHeader: string | undefined; + + if (authMode === 'oauth') { + const maybeToken = await getValidAccessToken(cookies); + token = maybeToken ?? undefined; + } else if (authMode === 'session') { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } else if (authMode === 'steam') { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!websiteCookieHeader) { + const steamId = getSteamIdFromCookies(cookies); + if (steamId) { + try { + const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials }); + const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? []; + const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth'); + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } catch {} + } + } + } else if (authMode === 'auto') { + const maybeToken = await getValidAccessToken(cookies); + if (maybeToken) { + token = maybeToken; + } else { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!websiteCookieHeader) { + const steamId = getSteamIdFromCookies(cookies); + if (steamId) { + try { + const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials }); + const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? []; + const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth'); + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } catch {} + } + } + } + } else { + // none + } + // Wrap fetch to track upstream BL requests + let blCallCount = 0; + let blLastUrl: string | undefined; + let blLastHeaders: Record | undefined; + const wrappedFetch: typeof fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const inputUrl = typeof input === 'string' ? input : (input instanceof URL ? input.toString() : (input as Request).url); + const isBL = String(inputUrl).startsWith('https://api.beatleader.com'); + if (isBL) { + blCallCount += 1; + blLastUrl = String(inputUrl); + const hdrs: Record = {}; + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((v, k) => { hdrs[k] = v; }); + } else if (Array.isArray(init.headers)) { + for (const [k, v] of init.headers) { if (v != null) hdrs[k] = String(v); } + } else { + Object.entries(init.headers as Record).forEach(([k, v]) => { if (v != null) hdrs[k] = String(v); }); + } + } + blLastHeaders = hdrs; + } + return fetch(input as any, init as any); + }; + const api = createBeatLeaderAPI(wrappedFetch, token, websiteCookieHeader); + const resolvedAuth = token ? 'oauth' : (websiteCookieHeader ? 'session' : 'none'); const includeScores = url.searchParams.get('scores'); try { if (includeScores === '1') { - const data = await api.getPlayerScores(params.id, Object.fromEntries(url.searchParams)); - return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); + const playerId = params.id as string; + const data = await api.getPlayerScores(playerId, Object.fromEntries(url.searchParams)); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); } - const data = await api.getPlayer(params.id); - return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); + const playerId = params.id as string; + const data = await api.getPlayer(playerId); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + if (blLastHeaders) { + const hasAuthHeader = blLastHeaders['Authorization'] || blLastHeaders['authorization']; + const hasCookieHeader = blLastHeaders['Cookie'] || blLastHeaders['cookie']; + if (debug) { + if (hasAuthHeader) headers['x-bl-last-authorization'] = hasAuthHeader as string; + if (hasCookieHeader) headers['x-bl-last-cookie'] = hasCookieHeader as string; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', `\"Authorization: ${hasAuthHeader}\"`); + if (hasCookieHeader) curlParts.push('-H', `\"Cookie: ${hasCookieHeader}\"`); + headers['x-bl-curl'] = curlParts.join(' '); + } else { + const authTag = hasAuthHeader ? 'present' : 'absent'; + const cookieTag = hasCookieHeader ? 'present' : 'absent'; + headers['x-bl-last-authorization'] = authTag; + headers['x-bl-last-cookie'] = cookieTag; + const curlParts = [ 'curl', '-sS', '-X', 'GET', blLastUrl ?? '' ]; + if (hasAuthHeader) curlParts.push('-H', '\"Authorization: Bearer \"'); + if (hasCookieHeader) curlParts.push('-H', '\"Cookie: \"'); + headers['x-bl-curl'] = curlParts.join(' '); + } + } + return new Response(JSON.stringify(data), { headers }); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; - return new Response(JSON.stringify({ error: message }), { status: 502 }); + const headers: Record = { 'content-type': 'application/json', 'x-bl-auth-mode': resolvedAuth, 'x-bl-auth-requested': authMode, 'x-bl-call-count': String(blCallCount) }; + if (blLastUrl) headers['x-bl-last-url'] = blLastUrl; + return new Response(JSON.stringify({ error: message }), { status: 502, headers }); } }; diff --git a/src/routes/api/beatleader/user/profile/+server.ts b/src/routes/api/beatleader/user/profile/+server.ts new file mode 100644 index 0000000..089844c --- /dev/null +++ b/src/routes/api/beatleader/user/profile/+server.ts @@ -0,0 +1,36 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { getBeatLeaderSessionCookieHeader, getValidAccessToken } from '$lib/server/beatleaderAuth'; + +const BL_PROFILE_URL = 'https://api.beatleader.com/user/profile'; + +export const PATCH: RequestHandler = async ({ url, cookies, fetch }) => { + const token = await getValidAccessToken(cookies); + const cookieHeader = token ? null : getBeatLeaderSessionCookieHeader(cookies); + if (!token && !cookieHeader) { + const redirect = url.searchParams.get('redirect_uri') ?? '/'; + return new Response( + JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login?redirect_uri=' + encodeURIComponent(redirect) }), + { status: 401, headers: { 'content-type': 'application/json' } } + ); + } + + const qs = url.searchParams.toString(); + const endpoint = qs ? `${BL_PROFILE_URL}?${qs}` : BL_PROFILE_URL; + + try { + const upstreamHeaders: Record = token ? { Authorization: `Bearer ${token}` } : cookieHeader ? { Cookie: cookieHeader } : {}; + const res = await fetch(endpoint, { method: 'PATCH', headers: upstreamHeaders }); + const text = await res.text(); + const responseHeaders: Record = { + 'content-type': res.headers.get('content-type') || 'text/plain' + }; + const retryAfter = res.headers.get('Retry-After'); + if (res.status === 429 && retryAfter) responseHeaders['Retry-After'] = retryAfter; + return new Response(text, { status: res.status, headers: responseHeaders }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Upstream request failed'; + return new Response(JSON.stringify({ error: message }), { status: 502, headers: { 'content-type': 'application/json' } }); + } +}; + + diff --git a/src/routes/api/beatsaver/curated/+server.ts b/src/routes/api/beatsaver/curated/+server.ts new file mode 100644 index 0000000..5057965 --- /dev/null +++ b/src/routes/api/beatsaver/curated/+server.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from './$types'; +import { createBeatSaverAPI } from '$lib/server/beatsaver'; + +export const GET: RequestHandler = async ({ fetch, url }) => { + try { + const api = createBeatSaverAPI(fetch); + const useCacheParam = url.searchParams.get('cache'); + const useCache = useCacheParam === null ? true : useCacheParam !== 'false'; + const maxPagesParam = url.searchParams.get('maxPages'); + const maxPages = maxPagesParam ? Number(maxPagesParam) : undefined; + const songs = await api.getCuratedSongs(useCache, { maxPages, tolerateErrors: true }); + return new Response(JSON.stringify({ songs }), { + headers: { 'content-type': 'application/json' } + }); + } catch (err) { + // Log server-side for debugging + console.error('GET /api/beatsaver/curated failed:', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'content-type': 'application/json' } }); + } +}; + + diff --git a/src/routes/api/steam/status/+server.ts b/src/routes/api/steam/status/+server.ts new file mode 100644 index 0000000..809d56e --- /dev/null +++ b/src/routes/api/steam/status/+server.ts @@ -0,0 +1,12 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { getSteamIdFromCookies } from '$lib/server/steam'; + +export const GET: RequestHandler = async ({ cookies }) => { + const steamId = getSteamIdFromCookies(cookies); + return new Response( + JSON.stringify({ connected: Boolean(steamId), steamId }), + { headers: { 'content-type': 'application/json' } } + ); +}; + + diff --git a/src/routes/api/steam/ticket/+server.ts b/src/routes/api/steam/ticket/+server.ts new file mode 100644 index 0000000..6a026a8 --- /dev/null +++ b/src/routes/api/steam/ticket/+server.ts @@ -0,0 +1,51 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { spawn } from 'node:child_process'; + +function runCommand(command: string, args: string[], timeoutMs = 8000): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + let err = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('Timed out')); + }, timeoutMs); + child.stdout.on('data', (d) => { out += String(d); }); + child.stderr.on('data', (d) => { err += String(d); }); + child.on('error', (e) => { clearTimeout(timer); reject(e); }); + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0 && out.trim().length > 0) resolve(out.trim()); + else reject(new Error(err || `Exited with code ${code}`)); + }); + }); +} + +/** + * GET /api/steam/ticket + * Returns a Steam session ticket as { ticket: string }. + * + * Implementation notes: + * - In production Tauri, this should be replaced by a Tauri command using the Steamworks SDK. + * - For now, this endpoint optionally runs a local helper binary specified by BL_STEAM_TICKET_CMD + * that prints the ticket to stdout. + */ +export const GET: RequestHandler = async () => { + const helper = process.env.BL_STEAM_TICKET_CMD?.trim(); + if (!helper) { + return new Response( + JSON.stringify({ error: 'Steam ticket helper not configured', hint: 'Set BL_STEAM_TICKET_CMD to a helper binary that prints the Steam session ticket.' }), + { status: 501, headers: { 'content-type': 'application/json' } } + ); + } + try { + const [cmd, ...rest] = helper.split(/\s+/); + const ticket = await runCommand(cmd, rest); + return new Response(JSON.stringify({ ticket }), { headers: { 'content-type': 'application/json' } }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'content-type': 'application/json' } }); + } +}; + + diff --git a/src/routes/auth/beatleader/callback/+server.ts b/src/routes/auth/beatleader/callback/+server.ts new file mode 100644 index 0000000..58600e0 --- /dev/null +++ b/src/routes/auth/beatleader/callback/+server.ts @@ -0,0 +1,39 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { consumeAndValidateState, exchangeCodeForTokens, setTokens, clearTokens, consumeRedirectCookie, getValidAccessToken } from '$lib/server/beatleaderAuth'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (!code) { + return new Response('Missing code', { status: 400 }); + } + + if (!consumeAndValidateState(cookies, state)) { + return new Response('Invalid state', { status: 400 }); + } + + const tokenData = await exchangeCodeForTokens(url.origin, code); + if (!tokenData || !tokenData.access_token) { + clearTokens(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 {} + + // 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/login/+server.ts b/src/routes/auth/beatleader/login/+server.ts new file mode 100644 index 0000000..62efc97 --- /dev/null +++ b/src/routes/auth/beatleader/login/+server.ts @@ -0,0 +1,27 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { buildAuthorizeUrl, createOAuthState, setStateCookie, setRedirectCookie, setBeatLeaderSessionFromSetCookieHeaders } from '$lib/server/beatleaderAuth'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const origin = url.origin; + const state = createOAuthState(); + setStateCookie(cookies, state); + // Persist desired post-login redirect for callback + const redirectTo = url.searchParams.get('redirect_uri'); + setRedirectCookie(cookies, redirectTo ?? '/'); + // Align with BeatLeader's documented scope naming (prefixed with scp:) + const scopes = ['scp:profile', 'scp:offline_access']; + const authUrl = buildAuthorizeUrl(origin, scopes); + // Always show consent to avoid silent failures and make the grant explicit + authUrl.searchParams.set('prompt', 'consent'); + authUrl.searchParams.set('state', state); + // If user already has a BeatLeader website session in the current browser, prime our session by hitting a lightweight endpoint. + try { + const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials }); + // If the probe set any cookies, capture them for future session-based calls + const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? []; + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + } catch {} + return new Response(null, { status: 302, headers: { Location: authUrl.toString() } }); +}; + + diff --git a/src/routes/auth/beatleader/logout-all/+server.ts b/src/routes/auth/beatleader/logout-all/+server.ts new file mode 100644 index 0000000..c0e0850 --- /dev/null +++ b/src/routes/auth/beatleader/logout-all/+server.ts @@ -0,0 +1,11 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { clearTokens, clearBeatLeaderSession } from '$lib/server/beatleaderAuth'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + clearTokens(cookies); + clearBeatLeaderSession(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 new file mode 100644 index 0000000..4353fdb --- /dev/null +++ b/src/routes/auth/beatleader/logout/+server.ts @@ -0,0 +1,10 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { clearTokens } from '$lib/server/beatleaderAuth'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + clearTokens(cookies); + const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; + return new Response(null, { status: 302, headers: { Location: redirectTo } }); +}; + + diff --git a/src/routes/auth/beatleader/session/login/+server.ts b/src/routes/auth/beatleader/session/login/+server.ts new file mode 100644 index 0000000..21d57f7 --- /dev/null +++ b/src/routes/auth/beatleader/session/login/+server.ts @@ -0,0 +1,76 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { setBeatLeaderSessionFromSetCookieHeaders } from '$lib/server/beatleaderAuth'; + +/** + * Session-based login mimicking beatleader-website signin with login/password. + * Proxies credentials to BeatLeader and captures Set-Cookie, storing them as a session for server-side authenticated calls. + * + * Supported body formats: + * - application/json: { login: string, password: string, action?: 'login' | 'signup' } + * - multipart/form-data or application/x-www-form-urlencoded with fields: login, password, action + */ +export const POST: RequestHandler = async ({ request, cookies, fetch }) => { + let login = ''; + let password = ''; + let action = 'login'; + + const contentType = request.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const body = (await request.json()) as any; + login = String(body.login ?? '').trim(); + password = String(body.password ?? '').trim(); + if (body.action) action = String(body.action); + } else { + const form = await request.formData(); + login = String(form.get('login') ?? '').trim(); + password = String(form.get('password') ?? '').trim(); + action = String(form.get('action') ?? 'login'); + } + + if (!login || !password) { + return new Response(JSON.stringify({ error: 'login and password are required' }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); + } + + const fd = new FormData(); + fd.set('action', action); + fd.set('login', login); + fd.set('password', password); + + // Proxy to BeatLeader website login endpoint + const blRes = await fetch('https://api.beatleader.com/signinoculus', { + method: 'POST', + body: fd, + // Do not send our cookies to third-party; we're simply capturing upstream cookies from response + redirect: 'manual' + }); + + // In website, successful response is empty text; otherwise contains an error message + const text = await blRes.text(); + + // Capture Set-Cookie headers regardless of body + try { + const setCookieHeaders = (blRes.headers as any).getSetCookie?.() ?? blRes.headers.get('set-cookie')?.split(',') ?? []; + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + } catch {} + + if (!blRes.ok) { + return new Response(JSON.stringify({ error: text || `BeatLeader returned ${blRes.status}` }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); + } + + if (text && text.length > 0) { + return new Response(JSON.stringify({ error: text }), { + status: 401, + headers: { 'content-type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }); +}; + + diff --git a/src/routes/auth/beatleader/session/logout/+server.ts b/src/routes/auth/beatleader/session/logout/+server.ts new file mode 100644 index 0000000..9adef65 --- /dev/null +++ b/src/routes/auth/beatleader/session/logout/+server.ts @@ -0,0 +1,9 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { clearBeatLeaderSession } from '$lib/server/beatleaderAuth'; + +export const POST: RequestHandler = async ({ cookies }) => { + clearBeatLeaderSession(cookies); + return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }); +}; + + diff --git a/src/routes/auth/beatleader/steam-ticket/+server.ts b/src/routes/auth/beatleader/steam-ticket/+server.ts new file mode 100644 index 0000000..5720072 --- /dev/null +++ b/src/routes/auth/beatleader/steam-ticket/+server.ts @@ -0,0 +1,66 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { setBeatLeaderSessionFromSetCookieHeaders } from '$lib/server/beatleaderAuth'; + +/** + * POST /auth/beatleader/steam-ticket + * Body: { ticket: string } or form-data ticket=... + * + * Mirrors BeatLeader mod auth flow by forwarding the Steam session ticket + * to BeatLeader /signin with provider=steamTicket, capturing BL website cookies. + */ +export const POST: RequestHandler = async ({ request, cookies, fetch }) => { + try { + const contentType = request.headers.get('content-type') || ''; + let ticket = ''; + if (contentType.includes('application/json')) { + const body = await request.json(); + ticket = String((body as any)?.ticket ?? '').trim(); + } else { + const form = await request.formData(); + ticket = String(form.get('ticket') ?? '').trim(); + } + + if (!ticket) { + return new Response(JSON.stringify({ error: 'Missing ticket' }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); + } + + const form = new FormData(); + form.set('ticket', ticket); + form.set('provider', 'steamTicket'); + form.set('returnUrl', '/'); + + const res = await fetch('https://api.beatleader.com/signin', { + method: 'POST', + body: form, + redirect: 'manual' + }); + + // Capture BL session cookies regardless of status + try { + const setCookieHeaders = (res.headers as any).getSetCookie?.() ?? res.headers.get('set-cookie')?.split(',') ?? []; + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + } catch {} + + const ok = res.status >= 200 && res.status < 400; + if (!ok) { + const text = await res.text().catch(() => ''); + return new Response(JSON.stringify({ error: `BeatLeader signin failed (${res.status})`, details: text }), { + status: 400, + headers: { 'content-type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'content-type': 'application/json' } + }); + } +}; + + diff --git a/src/routes/auth/steam/callback/+server.ts b/src/routes/auth/steam/callback/+server.ts new file mode 100644 index 0000000..f4d65e0 --- /dev/null +++ b/src/routes/auth/steam/callback/+server.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { setSteamIdCookie, verifySteamOpenIDResponse } from '$lib/server/steam'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const sp = url.searchParams; + const steamId = await verifySteamOpenIDResponse(sp); + if (!steamId) { + return new Response('Steam verification failed', { status: 400 }); + } + setSteamIdCookie(cookies, steamId); + const redirectTo = sp.get('redirect_uri') ?? '/'; + return new Response(null, { status: 302, headers: { Location: redirectTo } }); +}; + + diff --git a/src/routes/auth/steam/login/+server.ts b/src/routes/auth/steam/login/+server.ts new file mode 100644 index 0000000..bd9cb00 --- /dev/null +++ b/src/routes/auth/steam/login/+server.ts @@ -0,0 +1,10 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { buildSteamOpenIDLoginUrl } from '$lib/server/steam'; + +export const GET: RequestHandler = async ({ url }) => { + const redirectTo = url.searchParams.get('redirect_uri'); + const loginUrl = buildSteamOpenIDLoginUrl(url.origin, redirectTo); + return new Response(null, { status: 302, headers: { Location: loginUrl.toString() } }); +}; + + diff --git a/src/routes/auth/steam/logout/+server.ts b/src/routes/auth/steam/logout/+server.ts new file mode 100644 index 0000000..26ee5b7 --- /dev/null +++ b/src/routes/auth/steam/logout/+server.ts @@ -0,0 +1,10 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { clearSteamCookie } from '$lib/server/steam'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + clearSteamCookie(cookies); + const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; + return new Response(null, { status: 302, headers: { Location: redirectTo } }); +}; + + diff --git a/src/routes/guides/+page.svelte b/src/routes/guides/+page.svelte index 836a89b..be096c6 100644 --- a/src/routes/guides/+page.svelte +++ b/src/routes/guides/+page.svelte @@ -3,19 +3,14 @@

Community-written tips and guides for improving your Beat Saber game. Contributions welcome.

- {#each [ - 'Setup & Mods', - 'Finding Great Maps', - 'Improving Accuracy', - 'Fitness & Endurance', - 'Controller Settings', - 'Troubleshooting' - ] as title} -
-

{title}

-

Draft

-
- {/each} + +

BeatLeader Authentication

+

Connect BeatLeader to enhance tools like Compare Players.

+
+ +

Finding New Songs (BeatLeader)

+

Month-by-month search using unranked stars, tech rating, and friend filters.

+
diff --git a/src/routes/guides/beatleader-auth/+page.svelte b/src/routes/guides/beatleader-auth/+page.svelte new file mode 100644 index 0000000..479a23b --- /dev/null +++ b/src/routes/guides/beatleader-auth/+page.svelte @@ -0,0 +1,284 @@ + + +
+

BeatLeader Authentication

+
+ Educational use only: The information and resources on this page are for learning purposes. Do not use them for real authentication or accessing accounts. +
+

+ This app supports three ways to access your BeatLeader data: Steam, OAuth, and a website‑style session. +

+ + + + + + + + +
+
+
Steam
+
{hasSteam ? `Linked (${steamId})` : 'Not linked'}
+ {#if hasSteam} +
+ +
+ {/if} +
+
+
OAuth
+
{oauthConnected ? 'Connected' : 'Not connected'}
+ {#if oauthConnected} +
+ +
+ {/if} +
+
+
Website Session
+
{hasSession ? 'Active (captured via Steam)' : 'Not active'}
+ {#if hasSession} +
{ await fetch('/auth/beatleader/session/logout', { method: 'POST' }); await loadStatus(); }} class="mt-2"> + +
+ {/if} +
+
+ +

+ Default API auth is Steam. You can override per request using ?auth=steam|oauth|session|auto|none. +

+ +

+ Tools like Compare Players + can show unranked star ratings when your BeatLeader account is a supporter and ShowAllRatings is enabled. +

+ +

Steam Login

+

+ Authenticate via Steam OpenID to link your Steam account. Then use the BeatLeader Auth Tool with your Steam session ticket to capture a website session. +

+ +

OAuth Login

+
    +
  • scp:profile to read your user profile.
  • +
  • scp:offline_access to refresh your session without re‑prompting (optional).
  • +
+

+ You can still login with OAuth when needed: Login with BeatLeader (OAuth) +

+

+ After OAuth login, we attempt to enable ShowAllRatings automatically. If your BL account is not a supporter, BL will ignore it. +

+ +

Session Login (Website‑style)

+

+ Most users authenticate on the BeatLeader website using Steam. Use the BeatLeader Auth Tool to submit a Steam session ticket; we’ll create a BL website session for server‑side calls where OAuth is not honored. +

+

+ The current session status is available via GET /api/beatleader/oauth/status as hasSession. +

+ +

Requirements to see unranked star ratings

+
    +
  • You must authenticate with a user identity that BeatLeader recognizes for profile flags like ShowAllRatings. For the endpoints listed below, this currently means a website session cookie, not an OAuth bearer.
  • +
  • Your BeatLeader account must be a supporter (tipper/supporter/sponsor).
  • +
  • Your BL profile setting ShowAllRatings must be enabled. We auto‑enable it after login; if you're not a supporter, BL will ignore it.
  • +
+ +

OAuth tokens vs. website session for scores/stars

+

+ Some read endpoints on BeatLeader currently do not honor OAuth bearer tokens for determining the current user, and instead rely on the cookie‑authenticated website session: +

+
    +
  • GET /player/{id}/scores
  • +
  • GET /leaderboards/hash/{hash}
  • +
+

+ For these endpoints, an OAuth Authorization: Bearer ... header will not affect user‑specific visibility like ShowAllRatings. They read the user from the website session cookie. In our debug panel this appears as Using auth: oauth with Last Cookie: absent — and unranked stars may be hidden even if your account is eligible. +

+

Workarounds

+
    +
  • Use a website session: Use the BeatLeader Auth Tool with your Steam session ticket. We store the BL session cookie and attach it to server calls. Debug will show Using auth: session and Last Cookie: present.
  • +
  • Force session for a request: Append ?auth=session to our API URLs to prefer website session over OAuth for that call. Example: /api/beatleader?path=/leaderboards/hash/{hash}&auth=session.
  • +
  • Prefer session when both exist: Our tools default to ?auth=auto (use OAuth if present, then session). If you have both and need session for these endpoints, explicitly pass ?auth=session or temporarily sign out OAuth at /auth/beatleader/logout.
  • +
+

+ Other endpoints (e.g., /oauth2/identity, some write operations, and endpoints that explicitly check OAuth scopes) do honor OAuth bearer tokens. This limitation is specific to a few read endpoints listed above. +

+ +

OAuth App Setup (for developers)

+
    +
  1. + Configure BeatLeader OAuth credentials here: BL OAuth Setup. + If you already have credentials, you can paste them there. +
  2. +
  3. + Use the OAuth Login above; we request scp:offline_access to allow refresh without re‑prompting. +
  4. +
+ +

How this app uses your auth

+
    +
  • Default auth mode is Steam. You can override per‑request with ?auth=steam|oauth|session|auto|none.
  • +
  • Server routes use your OAuth token when available; otherwise some endpoints fall back to your BL session cookie:
  • +
      +
    • /api/beatleader?path=/user supports OAuth token or session cookie.
    • +
    • /api/beatleader/user/profile (PATCH) uses OAuth token or session cookie to toggle flags like ShowAllRatings.
    • +
    • Other public GETs are proxied unauthenticated or with your token when relevant.
    • +
    +
  • Other tools call these endpoints and will include unranked stars if your user auth allows it and ShowAllRatings is enabled.
  • +
+ +

Desktop app (Tauri) implementation plan

+

+ The desktop build of this app will automatically obtain a Steam session ticket using the Steamworks SDK and create your BeatLeader website session without manual input. High‑level plan: +

+
    +
  1. Integrate Steamworks in Tauri: add Steamworks SDK and implement a Tauri command (e.g., getSteamSessionTicket) that calls ISteamUser.GetAuthSessionTicket and returns the ticket string.
  2. +
  3. Wire the ticket endpoint: make GET /api/steam/ticket call the Tauri command. Until Tauri is in place, it supports a local helper via BL_STEAM_TICKET_CMD that outputs a ticket to stdout.
  4. +
  5. Create BL website session: the tool at BeatLeader Auth Tool fetches the Steam ticket and then POSTs it to /auth/beatleader/steam-ticket (forwards to api.beatleader.com/signin with provider=steamTicket). We capture BL cookies server‑side.
  6. +
  7. Verify: GET /api/beatleader/oauth/status should report hasSession: true.
  8. +
  9. Use session for specific endpoints: For /player/{id}/scores and /leaderboards/hash/{hash} (where OAuth is ignored), call our proxy with ?auth=session or keep ?auth=auto when no OAuth token is present.
  10. +
+

+ Security note: the Steam session ticket is used only to establish a BeatLeader website session and is never stored. We store only the BL cookies (httpOnly) necessary for server‑side requests. +

+ +

Why connect?

+
    +
  • Compare Players can show BeatLeader star ratings alongside map tiles.
  • +
  • If your BL account grants visibility into unranked stars, those will appear too.
  • +
+ +

Technical Details

+

+ For developers curious about the implementation, here's how BeatLeader's OAuth2.0 system works: +

+ +

BeatLeader Server (Backend)

+

+ BeatLeader uses OpenIddict as its OAuth2.0/OpenID Connect server implementation. The token creation is handled automatically by OpenIddict rather than custom code. +

+ +

Key Components:

+
    +
  • Token Endpoint: /oauth2/token - Handled automatically by OpenIddict
  • +
  • Authorization Endpoint: /oauth2/authorize - Custom controller in AuthenticationController.cs
  • +
  • User Info Endpoint: /oauth2/identity - Returns user profile data
  • +
  • Client Management: /developer/app* - OAuth application registration and management
  • +
+ +

Authorization Flow:

+
    +
  1. GET /oauth2/authorize: Renders consent screen and validates user authentication
  2. +
  3. POST /oauth2/authorize: Processes user consent and creates authorization code via SignIn()
  4. +
  5. POST /oauth2/token: OpenIddict automatically exchanges authorization code for access/refresh tokens
  6. +
+ +

Configuration (Startup.cs):

+
options.SetAuthorizationEndpointUris("oauth2/authorize")
+       .SetTokenEndpointUris("oauth2/token")
+       .SetUserinfoEndpointUris("oauth2/identity");
+
+options.AllowAuthorizationCodeFlow()
+       .AllowRefreshTokenFlow();
+ +

BeatLeader Website (Frontend)

+

+ The website provides the OAuth consent UI and developer portal, but delegates all token operations to the API. +

+ +

Key Components:

+
    +
  • Consent Screen: OauthSignIn.svelte - Renders the authorization approval UI
  • +
  • Developer Portal: DeveloperPortal.svelte - OAuth application management
  • +
  • App Management: OauthApp.svelte - Create/edit OAuth applications
  • +
+ +

Authorization Flow:

+
    +
  1. Consent UI: Posts to BL_API_URL + 'oauth2/authorize' with user approval
  2. +
  3. Client Info: Fetches app details from BL_API_URL + 'oauthclient/info'
  4. +
  5. CSRF Protection: Uses antiforgery tokens from BL_API_URL + 'oauthclient/antiforgery'
  6. +
+ +

Developer Portal Features:

+
    +
  • OAuth application registration with custom client IDs
  • +
  • Scope management (profile, clan, offline_access)
  • +
  • Redirect URL configuration
  • +
  • Client secret generation and reset
  • +
  • Test authorization URL generation
  • +
+ +

OAuth Flow Summary

+
    +
  1. Client redirects user to /oauth2/authorize with client_id, scope, response_type=code
  2. +
  3. BeatLeader validates user authentication and renders consent screen
  4. +
  5. User approves → authorization code generated via OpenIddict
  6. +
  7. Client exchanges code for tokens at /oauth2/token (handled by OpenIddict)
  8. +
  9. Client uses access token for API calls, refresh token for renewal
  10. +
+ +

Security Features

+
    +
  • CSRF Protection: Antiforgery tokens on consent forms
  • +
  • Scope Validation: Only requested scopes are granted
  • +
  • Client Validation: Registered applications only
  • +
  • Token Security: Signed/encrypted tokens with X.509 certificates
  • +
  • Authorization Storage: Permanent authorizations to avoid repeated consent
  • +
+ +

Sign out

+
    +
  • Everything: POST /auth/beatleader/logout-all (clears OAuth tokens and session)
  • +
  • Only OAuth tokens: visit /auth/beatleader/logout
  • +
  • Only session cookie: POST /auth/beatleader/session/logout
  • +
+
curl -X POST 'https://{your-host}/auth/beatleader/logout-all'
+ +

Troubleshooting

+
    +
  • Unranked stars missing: confirm you’re logged in with a supporter BL account and that ShowAllRatings is enabled.
  • +
  • Check connection: GET /api/beatleader/oauth/status shows connected, hasCreds, and hasSession.
  • +
  • Re‑login: use OAuth at /auth/beatleader/login or submit a fresh Steam session ticket in the BeatLeader Auth Tool.
  • +
  • If the debug panel shows Using auth: oauth and Last Cookie: absent on /player/{id}/scores or /leaderboards/hash/{hash}, force session with ?auth=session and ensure your website session is active via the tool.
  • +
  • Redirect mismatch: ensure your app’s redirect in BL developer portal matches {origin}/auth/beatleader/callback.
  • +
+
+ + diff --git a/src/routes/guides/finding-new-songs/+page.svelte b/src/routes/guides/finding-new-songs/+page.svelte new file mode 100644 index 0000000..ea42217 --- /dev/null +++ b/src/routes/guides/finding-new-songs/+page.svelte @@ -0,0 +1,78 @@ +
+

Finding New Songs with BeatLeader

+

+ A fast, repeatable workflow to discover fresh maps using BeatLeader’s powerful search. It relies on + supporter‑only unranked star ratings (ShowAllRatings) and sorts by techRating to surface interesting patterns. +

+ + + +

Requirements

+
    +
  • BeatLeader supporter role on your BL account (Patreon/tipper/supporter/sponsor).
  • +
  • ShowAllRatings enabled on your BL profile. See BeatLeader Authentication for how to sign in and enable it.
  • +
+ +

Search setup

+

Use the BeatLeader maps search with these filters:

+
    +
  • Date range: pick a single month (e.g., Dec 1 → Jan 1). Iterate month by month.
  • +
  • Stars: set a band to control density and overall difficulty (e.g., 8–10 for fun, 6–8 for warm‑ups).
  • +
  • Sort by: techRating.
  • +
  • Mode / Difficulty: Standard, ExpertPlus (adjust for your preference).
  • +
+ + +

Note: The example uses a specific month via date_from/date_to. Use the BL UI date picker to select your current month.

+ +

Monthly workflow

+
    +
  1. Pick a month and set the date filter to that exact window.
  2. +
  3. Set stars to your target band (e.g., 8–10★). Leave ranked off so unranked stars show.
  4. +
  5. Sort by techRating. You should see ~100 results for a typical month.
  6. +
  7. Quick‑scan the list: +
      +
    • The very top often contains maps with ultra‑weird patterns.
    • +
    • The bottom often contains very simple/boring patterns.
    • +
    • The juicy middle (~70–80% of results) is usually worth considering.
    • +
    +
  8. +
  9. For each candidate: preview audio, check the mapper, and watch replays to gauge fun factor and pattern quality.
  10. +
  11. Repeat for the next month.
  12. +
+ +

Collecting and downloading maps

+
    +
  • Download as you go: When you find a keeper, download the map immediately.
  • +
  • Playlist workflow: Use BeatLeader’s Add to playlist on each candidate to build a short list. Later, open that playlist and download it. In‑game, load the playlist, then use your playlist manager to download all songs in one go.
  • +
+ +

Shortcuts

+
    +
  • Friends‑played filter: add mytype=friendsPlayed to show maps your followed players have played. This turns your follow list into a taste‑making filter.
  • +
+ + +

Why this works

+
    +
  • Stars banding sets expectations for note density and cumulative pattern intensity.
  • +
  • Tech rating sort floats unusual patterning to the top and simpler maps to the bottom, leaving a large middle of promising, fun charts.
  • +
  • Monthly windows keep the set manageable and ensure you don’t miss new drops.
  • +
+ +

Tip: Create two tabs — one for 8–10★ “fun picks” and another for 6–8★ warmups — and work month by month.

+
+ + + diff --git a/src/routes/tools/+page.svelte b/src/routes/tools/+page.svelte index 1933517..860cd81 100644 --- a/src/routes/tools/+page.svelte +++ b/src/routes/tools/+page.svelte @@ -5,10 +5,7 @@
{#each [ { name: 'BeatLeader Compare Players', href: '/tools/beatleader-compare', desc: 'Find songs A played that B has not' }, - { name: 'PP Calculator', href: '#pp', desc: 'Soon' }, - { name: 'Map Search', href: '#search', desc: 'Soon' }, - { name: 'Replay Analyzer', href: '#replay', desc: 'Soon' }, - { name: 'Practice Slicer', href: '#slicer', desc: 'Soon' } + { name: 'BeatLeader Playlist Gap', href: '/tools/beatleader-playlist-gap', desc: 'Upload a playlist and find songs a player has not played' } ] as tool}
{tool.name}
@@ -17,12 +14,7 @@ {/each}
-
-
- -
-
-
+ diff --git a/src/routes/tools/beatleader-auth/+page.svelte b/src/routes/tools/beatleader-auth/+page.svelte new file mode 100644 index 0000000..fe0d54e --- /dev/null +++ b/src/routes/tools/beatleader-auth/+page.svelte @@ -0,0 +1,116 @@ + + +
+

BeatLeader Authentication

+

Establish a BeatLeader website session using your Steam session ticket. This mimics how the BeatLeader mod logs in.

+ +
+
+
Website Session
+
{hasSession ? 'Active (captured via Steam)' : 'Not active'}
+
+ + {#if hasSession} + + {/if} +
+
+ +
+
Steam Session Ticket
+

We can obtain your Steam session ticket using a local helper (bundled in Tauri). If unavailable, you can paste a ticket from a compatible client.

+
+ + {#if steamTicket} + Ticket fetched + {/if} +
+ +
+ + {#if message} + {message} + {/if} +
+
+
+ +

How it works

+
    +
  • We POST your ticket to BeatLeader /signin with provider=steamTicket.
  • +
  • BeatLeader sets website cookies; we store those server‑side and attach them to API calls that require a website session.
  • +
  • Use ?auth=session on our API where OAuth is ignored by BL (e.g., scores/leaderboards that show unranked stars).
  • +
+
+ + diff --git a/src/routes/tools/beatleader-compare/+page.svelte b/src/routes/tools/beatleader-compare/+page.svelte index c2f900d..46b3f60 100644 --- a/src/routes/tools/beatleader-compare/+page.svelte +++ b/src/routes/tools/beatleader-compare/+page.svelte @@ -34,7 +34,7 @@ let playerA = ''; let playerB = ''; - let songCount = 40; + let songCount = 10; let loading = false; let errorMsg: string | null = null; let results: SongItem[] = []; @@ -70,6 +70,17 @@ mapper?: string; }; let metaByHash: Record = {}; + + type StarInfo = { + stars?: number; + accRating?: number; + passRating?: number; + techRating?: number; + status?: number; + }; + // Keyed by `${hash}|${difficultyName}|${modeName}` for precise lookup + let starsByKey: Record = {}; + let loadingStars = false; async function fetchBeatSaverMeta(hash: string): Promise { try { @@ -99,6 +110,56 @@ } loadingMeta = false; } + + async function fetchBeatLeaderStarsByHash(hash: string): Promise { + try { + const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`); + if (!res.ok) return; + const data: any = await res.json(); + const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : []; + for (const lb of leaderboards) { + const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined; + const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard'; + if (!diffName || !modeName) continue; + const normalized = normalizeDifficultyName(diffName); + const key = `${hash}|${normalized}|${modeName}`; + const info: StarInfo = { + stars: lb?.difficulty?.stars ?? lb?.stars, + accRating: lb?.difficulty?.accRating, + passRating: lb?.difficulty?.passRating, + techRating: lb?.difficulty?.techRating, + status: lb?.difficulty?.status + }; + starsByKey = { ...starsByKey, [key]: info }; + } + } catch { + // ignore + } + } + + async function loadStarsForResults(items: SongItem[]): Promise { + const neededHashes = Array.from(new Set(items.map((i) => i.hash))); + if (neededHashes.length === 0) return; + loadingStars = true; + for (const h of neededHashes) { + await fetchBeatLeaderStarsByHash(h); + } + loadingStars = false; + } + + function difficultyToColor(name: string | undefined): string { + const n = (name ?? 'ExpertPlus').toLowerCase(); + if (n === 'easy') return 'MediumSeaGreen'; + if (n === 'normal') return '#59b0f4'; + if (n === 'hard') return 'tomato'; + if (n === 'expert') return '#bf2a42'; + if (n === 'expertplus' || n === 'expert+' || n === 'ex+' ) return '#8f48db'; + return '#8f48db'; + } + + + + function normalizeDifficultyName(value: number | string | null | undefined): string { if (value === null || value === undefined) return 'ExpertPlus'; @@ -163,6 +224,25 @@ return all; } + async function fetchAllScoresAnyTime(playerId: string, maxPages = 100): Promise { + const pageSize = 100; + let page = 1; + const all: BeatLeaderScore[] = []; + + while (page <= maxPages) { + const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`); + const data = (await res.json()) as BeatLeaderScoresResponse; + const batch = data.data ?? []; + if (batch.length === 0) break; + all.push(...batch); + page += 1; + } + + return all; + } + function loadHistory(): Record { try { const raw = localStorage.getItem('bl_compare_history'); @@ -239,13 +319,18 @@ const cutoff = getCutoffEpoch(); const [aScores, bScores] = await Promise.all([ fetchAllRecentScores(a, cutoff), - fetchAllRecentScores(b, cutoff) + fetchAllScoresAnyTime(b, 100) ]); const bHashes = new Set(); + const bLeaderboardIds = new Set(); for (const s of bScores) { - const hash = s.leaderboard?.song?.hash ?? undefined; - if (hash) bHashes.add(hash); + const rawHash = s.leaderboard?.song?.hash ?? undefined; + const hashLower = rawHash ? String(rawHash).toLowerCase() : undefined; + const lbIdRaw = (s.leaderboard as any)?.id ?? (s.leaderboard as any)?.leaderboardId; + const lbId = lbIdRaw != null ? String(lbIdRaw) : undefined; + if (hashLower) bHashes.add(hashLower); + if (lbId) bLeaderboardIds.add(lbId); } const history = loadHistory(); @@ -256,24 +341,25 @@ const t = parseTimeset(entry.timeset); if (!t || t < cutoff) continue; - const hash = entry.leaderboard?.song?.hash ?? undefined; + const rawHash = entry.leaderboard?.song?.hash ?? undefined; const diffValue = entry.leaderboard?.difficulty?.value ?? undefined; const modeName = entry.leaderboard?.difficulty?.modeName ?? 'Standard'; const leaderboardIdRaw = (entry.leaderboard as any)?.id ?? (entry.leaderboard as any)?.leaderboardId; const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined; - if (!hash) continue; - if (bHashes.has(hash)) continue; // B has played this song + if (!rawHash) continue; + const hashLower = String(rawHash).toLowerCase(); + if (bHashes.has(hashLower) || (leaderboardId && bLeaderboardIds.has(leaderboardId))) continue; // B has played this song const diffName = normalizeDifficultyName(diffValue); - const historyDiffs = history[hash] ?? []; + const historyDiffs = history[rawHash] ?? []; if (historyDiffs.includes(diffName)) continue; // used previously - const key = `${hash}|${diffName}|${modeName}`; + const key = `${rawHash}|${diffName}|${modeName}`; if (runSeen.has(key)) continue; runSeen.add(key); candidates.push({ - hash, + hash: rawHash, difficulties: [{ name: diffName, characteristic: modeName ?? 'Standard' }], timeset: t, leaderboardId @@ -295,6 +381,8 @@ page = 1; // Load BeatSaver metadata (covers, titles) for tiles loadMetaForResults(limited); + // Load BeatLeader star ratings per hash/diff + loadStarsForResults(limited); } catch (err) { errorMsg = err instanceof Error ? err.message : 'Unknown error'; } finally { @@ -316,21 +404,25 @@
-

BeatLeader: A vs B — Played‑Only Delta

+

BeatLeader: Compare Players

Maps Player A has played that Player B hasn't — last 12 months.

+
- - +
- - +
- - +
+ class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50" + on:click={() => { const key = metaByHash[item.hash]?.key; if (key) navigator.clipboard.writeText(`!bsr ${key}`); }} + disabled={!metaByHash[item.hash]?.key} + title="Copy !bsr" + >Copy !bsr
diff --git a/src/routes/tools/beatleader-oauth/+page.svelte b/src/routes/tools/beatleader-oauth/+page.svelte new file mode 100644 index 0000000..a336a8f --- /dev/null +++ b/src/routes/tools/beatleader-oauth/+page.svelte @@ -0,0 +1,283 @@ + + +
+

BeatLeader OAuth Setup

+

Register an OAuth application and store credentials for this site.

+ +
+ {#if blCredsStatus === 'unknown'} + Checking BeatLeader access… + {:else if blCredsStatus === 'configured'} + BeatLeader access: Connected + {#if blManageUrl} + Manage + {/if} + {:else} + BeatLeader access: Not connected + {#if blManageUrl} + Configure + {/if} + {/if} +
+ + {#if returnTo} + + {/if} + +
+
Manual setup (recommended):
+
    +
  1. + Open the BeatLeader developer portal: + beatleader.com/developer +
  2. +
  3. + Create a new application using the following values: +
    +
    +
    Suggested Client ID
    +
    + {recommendedClientId} + +
    +
    +
    +
    Redirect URL
    +
    + {origin}/auth/beatleader/callback + +
    +
    +
    +
    Scopes
    + scp:profile{scopes.includes('scp:offline_access') ? ',scp:offline_access' : ''} +
    +
    +
  4. +
  5. + After creation, copy the shown client secret and paste both values below to save locally. +
  6. +
+
+ + {#if existing} +
+
Existing credentials found.
+
Client ID: {existing.client_id}
+
Client Secret: {existing.client_secret}
+
+ {/if} + +
+ Enter credentials manually + +
+ + +
+
+ + +
+
+ +
+ +
+ + + + {#if message} +
{message}
+ {/if} + + {#if created} +
+
Client ID: {created.clientId}
+
Client Secret: {created.clientSecret}
+ +
+ {/if} +
+ + diff --git a/src/routes/tools/beatleader-playlist-gap/+page.svelte b/src/routes/tools/beatleader-playlist-gap/+page.svelte new file mode 100644 index 0000000..4f2ea25 --- /dev/null +++ b/src/routes/tools/beatleader-playlist-gap/+page.svelte @@ -0,0 +1,453 @@ + + +
+

BeatLeader: Playlist Gap

+

Upload a .bplist and enter a player ID to find songs they have not played.

+ +
+
+ + {#if selectedFileName} +
{selectedFileName}{#if parsedTitle} · title: {parsedTitle}{/if} · {playlistSongs.length} songs
+ {/if} +
+
+ +
+
+ +
+
+ + {#if errorMsg} +
{errorMsg}
+ {/if} + + {#if results.length > 0} +
+
+ {results.length} songs not played +
+
+ +
+
+ + {#if loadingMeta} +
Loading covers…
+ {/if} + +
+ {#each results as item} +
+
+ {#if metaByHash[item.hash?.toLowerCase?.() || '']?.coverURL} + {metaByHash[item.hash.toLowerCase()]?.songName + {:else} +
No cover
+ {/if} +
+
+
+ {metaByHash[item.hash.toLowerCase()]?.songName ?? item.hash} +
+ {#if metaByHash[item.hash.toLowerCase()]?.mapper} +
{metaByHash[item.hash.toLowerCase()]?.mapper}
+ {/if} +
+ +
+
+ openBeatLeader(item.hash)} + on:auxclick|preventDefault={() => openBeatLeader(item.hash)} + target="_blank" + rel="noopener" + title="Open in BeatLeader" + >BL + BSR + +
+
+
+ {/each} +
+ {/if} +
+ + + + diff --git a/tsconfig.json b/tsconfig.json index 0b2d886..9eee250 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["svelte", "vite/client", "node"] } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files