new exerimental tools, lessons learned about beatleader authentication (it requires steamworks sdk and steam ticket handling)
This commit is contained in:
parent
6c2066d784
commit
3460bfe401
15
README.md
15
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:
|
||||
|
||||
BIN
assets/voting-tracker.png
Normal file
BIN
assets/voting-tracker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
138
docs/decisions/frameworks.md
Normal file
138
docs/decisions/frameworks.md
Normal file
@ -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<br/>(TS + TailwindCSS)"]
|
||||
WebView["WebView<br/>(Tauri Runtime)"]
|
||||
SvelteKit --> WebView
|
||||
end
|
||||
|
||||
subgraph "Backend Logic"
|
||||
NodeJS["Embedded Node.js Runtime<br/>(TypeScript backend)"]
|
||||
Rust["Rust Commands<br/>(Tauri APIs / Native modules)"]
|
||||
end
|
||||
|
||||
subgraph "Platforms"
|
||||
Desktop["Desktop<br/>(Windows, macOS, Linux)"]
|
||||
Mobile["Mobile<br/>(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?
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
17
plebsaber.stream.code-workspace
Normal file
17
plebsaber.stream.code-workspace
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../../src/beatleader-website"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/beatleader-server"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/beatleader-mod"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
4
src/app.d.ts
vendored
4
src/app.d.ts
vendored
@ -3,7 +3,9 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
blAccessToken?: string | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
{#each links as link}
|
||||
<a href={link.href} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" class="btn-neon">Launch Tools</a>
|
||||
<a href="/tools/beatleader-compare" class="btn-neon">Compare Players</a>
|
||||
</nav>
|
||||
|
||||
<button class="md:hidden btn-neon px-3 py-1.5" on:click={toggle} aria-expanded={open} aria-controls="mobile-nav">
|
||||
@ -39,7 +39,7 @@
|
||||
{#each links as link}
|
||||
<a href={link.href} on:click={close} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" on:click={close} class="btn-neon w-max">Launch Tools</a>
|
||||
<a href="/tools/beatleader-compare" on:click={close} class="btn-neon w-max">Compare Players</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -3,20 +3,79 @@ const BASE_URL = 'https://api.beatleader.com';
|
||||
// Simple in-memory cache for GET requests to BeatLeader
|
||||
// Caches JSON responses by URL for a short TTL to reduce backend load
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_RETRIES = 5;
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 60_000;
|
||||
const BACKOFF_FACTOR = 2;
|
||||
type CacheEntry = { expiresAt: number; data: unknown };
|
||||
const responseCache: Map<string, CacheEntry> = new Map();
|
||||
const WEBSITE_COOKIE_HEADER = 'Cookie';
|
||||
|
||||
async function fetchJsonCached(fetchFn: typeof fetch, url: string, ttlMs = CACHE_TTL_MS): Promise<unknown> {
|
||||
export class RateLimitError extends Error {
|
||||
readonly status: number = 429;
|
||||
readonly retryAfterMs?: number;
|
||||
constructor(message: string, retryAfterMs?: number) {
|
||||
super(message);
|
||||
this.name = 'RateLimitError';
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWith429Retry(
|
||||
fetchFn: typeof fetch,
|
||||
url: string,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
let attempt = 0;
|
||||
let backoffMs = INITIAL_BACKOFF_MS;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
const res = await fetchFn(url, init);
|
||||
if (res.status === 429) {
|
||||
attempt += 1;
|
||||
const retryAfterHeader = res.headers.get('Retry-After');
|
||||
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
||||
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
|
||||
if (attempt > MAX_RETRIES) {
|
||||
throw new RateLimitError('BeatLeader rate limit exceeded', waitMs);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, waitMs));
|
||||
backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS);
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
attempt += 1;
|
||||
if (attempt > MAX_RETRIES) throw err;
|
||||
await new Promise((r) => setTimeout(r, backoffMs));
|
||||
backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJsonCached(
|
||||
fetchFn: typeof fetch,
|
||||
url: string,
|
||||
options: { headers?: Record<string, string> } = {},
|
||||
ttlMs = CACHE_TTL_MS
|
||||
): Promise<unknown> {
|
||||
const now = Date.now();
|
||||
const cached = responseCache.get(url);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.data;
|
||||
const hasAuth = Boolean(options.headers && (options.headers['Authorization'] || options.headers[WEBSITE_COOKIE_HEADER]));
|
||||
const cacheKey = hasAuth ? null : url;
|
||||
if (cacheKey) {
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetchFn(url);
|
||||
const res = await requestWith429Retry(fetchFn, url, { headers: options.headers });
|
||||
if (!res.ok) throw new Error(`BeatLeader request failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
responseCache.set(url, { expiresAt: now + ttlMs, data });
|
||||
if (cacheKey) {
|
||||
responseCache.set(cacheKey, { expiresAt: now + ttlMs, data });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -34,14 +93,24 @@ function buildQuery(params: QueryParams): string {
|
||||
|
||||
export class BeatLeaderAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly accessToken?: string;
|
||||
private readonly websiteCookieHeader?: string;
|
||||
|
||||
constructor(fetchFn: typeof fetch) {
|
||||
constructor(fetchFn: typeof fetch, accessToken?: string, websiteCookieHeader?: string) {
|
||||
this.fetchFn = fetchFn;
|
||||
this.accessToken = accessToken;
|
||||
this.websiteCookieHeader = websiteCookieHeader;
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> | undefined {
|
||||
if (this.accessToken) return { Authorization: `Bearer ${this.accessToken}` };
|
||||
if (this.websiteCookieHeader) return { [WEBSITE_COOKIE_HEADER]: this.websiteCookieHeader };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getPlayer(playerId: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getPlayerScores(
|
||||
@ -85,7 +154,7 @@ export class BeatLeaderAPI {
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}/scores${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getLeaderboard(
|
||||
@ -98,7 +167,7 @@ export class BeatLeaderAPI {
|
||||
const url = `${BASE_URL}/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(
|
||||
diff
|
||||
)}/${encodeURIComponent(mode)}${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getRankedLeaderboards(params: { stars_from?: number; stars_to?: number; page?: number; count?: number } = {}): Promise<unknown> {
|
||||
@ -110,12 +179,22 @@ export class BeatLeaderAPI {
|
||||
stars_to: params.stars_to
|
||||
});
|
||||
const url = `${BASE_URL}/leaderboards${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
|
||||
async getUser(): Promise<unknown> {
|
||||
const url = `${BASE_URL}/user`;
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() }, 30_000);
|
||||
}
|
||||
|
||||
async getLeaderboardsByHash(hash: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/leaderboards/hash/${encodeURIComponent(hash)}`;
|
||||
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
|
||||
}
|
||||
}
|
||||
|
||||
export function createBeatLeaderAPI(fetchFn: typeof fetch): BeatLeaderAPI {
|
||||
return new BeatLeaderAPI(fetchFn);
|
||||
export function createBeatLeaderAPI(fetchFn: typeof fetch, accessToken?: string, websiteCookieHeader?: string): BeatLeaderAPI {
|
||||
return new BeatLeaderAPI(fetchFn, accessToken, websiteCookieHeader);
|
||||
}
|
||||
|
||||
|
||||
|
||||
293
src/lib/server/beatleaderAuth.ts
Normal file
293
src/lib/server/beatleaderAuth.ts
Normal file
@ -0,0 +1,293 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const TOKEN_URL = 'https://api.beatleader.com/oauth2/token';
|
||||
|
||||
const ACCESS_COOKIE = 'bl_access_token';
|
||||
const REFRESH_COOKIE = 'bl_refresh_token';
|
||||
const EXPIRES_COOKIE = 'bl_expires_at';
|
||||
const SESSION_COOKIE = 'bl_session_cookie';
|
||||
const STATE_COOKIE = 'bl_oauth_state';
|
||||
const REDIRECT_COOKIE = 'bl_redirect_to';
|
||||
|
||||
// Persistent storage locations
|
||||
const DATA_DIR = '.data';
|
||||
const CREDS_FILE = 'beatleader_oauth.json';
|
||||
const TOKENS_FILE = 'beatleader_tokens.json';
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: '/',
|
||||
httpOnly: true as const,
|
||||
sameSite: 'lax' as const,
|
||||
secure: dev ? false : true
|
||||
};
|
||||
}
|
||||
|
||||
export function createOAuthState(): string {
|
||||
// Use crypto.randomUUID for sufficiently random state
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function setStateCookie(cookies: Cookies, state: string): void {
|
||||
cookies.set(STATE_COOKIE, state, { ...cookieOptions(), maxAge: 600 }); // 10 min
|
||||
}
|
||||
|
||||
export function consumeAndValidateState(cookies: Cookies, received: string | null): boolean {
|
||||
const stored = cookies.get(STATE_COOKIE);
|
||||
cookies.delete(STATE_COOKIE, cookieOptions());
|
||||
return Boolean(stored && received && stored === received);
|
||||
}
|
||||
|
||||
export function setRedirectCookie(cookies: Cookies, redirectTo: string | null | undefined): void {
|
||||
if (!redirectTo) return;
|
||||
try {
|
||||
const url = new URL(redirectTo, 'http://dummy');
|
||||
const value = url.pathname + (url.search || '') + (url.hash || '');
|
||||
cookies.set(REDIRECT_COOKIE, value, { ...cookieOptions(), maxAge: 600 });
|
||||
} catch {
|
||||
// ignore invalid redirect
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeRedirectCookie(cookies: Cookies): string | null {
|
||||
const value = cookies.get(REDIRECT_COOKIE) ?? null;
|
||||
cookies.delete(REDIRECT_COOKIE, cookieOptions());
|
||||
return value;
|
||||
}
|
||||
|
||||
export function setTokens(
|
||||
cookies: Cookies,
|
||||
tokenData: { access_token: string; refresh_token?: string; expires_in?: number }
|
||||
): void {
|
||||
const expiresAt = Date.now() + ((tokenData.expires_in ?? 3600) * 1000);
|
||||
cookies.set(ACCESS_COOKIE, tokenData.access_token, { ...cookieOptions(), maxAge: tokenData.expires_in ?? 3600 });
|
||||
if (tokenData.refresh_token) {
|
||||
// 30 days by default for refresh; adjust as needed
|
||||
cookies.set(REFRESH_COOKIE, tokenData.refresh_token, { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
}
|
||||
cookies.set(EXPIRES_COOKIE, String(expiresAt), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
|
||||
// Persist token data to disk for reuse across restarts (single-user assumption)
|
||||
try {
|
||||
writeJsonPersistent(TOKENS_FILE, {
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token ?? null,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
} catch {
|
||||
// ignore persistence errors
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens(cookies: Cookies): void {
|
||||
cookies.delete(ACCESS_COOKIE, cookieOptions());
|
||||
cookies.delete(REFRESH_COOKIE, cookieOptions());
|
||||
cookies.delete(EXPIRES_COOKIE, cookieOptions());
|
||||
try {
|
||||
deletePersistent(TOKENS_FILE);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist BeatLeader website session cookies captured from upstream Set-Cookie headers.
|
||||
* Only cookie name=value pairs are stored (attributes are discarded). Stored both in httpOnly cookie and on disk.
|
||||
*/
|
||||
export function setBeatLeaderSessionFromSetCookieHeaders(cookies: Cookies, setCookieHeaders: string[] | undefined | null): void {
|
||||
if (!setCookieHeaders || setCookieHeaders.length === 0) return;
|
||||
const nameValuePairs: string[] = [];
|
||||
for (const raw of setCookieHeaders) {
|
||||
const firstSemi = raw.indexOf(';');
|
||||
const pair = firstSemi === -1 ? raw.trim() : raw.slice(0, firstSemi).trim();
|
||||
if (!pair) continue;
|
||||
// Filter out obviously invalid pairs
|
||||
if (!pair.includes('=')) continue;
|
||||
const [name, value] = pair.split('=');
|
||||
if (!name || value === undefined) continue;
|
||||
nameValuePairs.push(`${name}=${value}`);
|
||||
}
|
||||
if (nameValuePairs.length === 0) return;
|
||||
try {
|
||||
cookies.set(SESSION_COOKIE, JSON.stringify(nameValuePairs), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
} catch {}
|
||||
try {
|
||||
writeJsonPersistent('beatleader_session.json', { cookies: nameValuePairs });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Cookie header string ("a=b; c=d") suitable for upstream requests, if a BL session is stored.
|
||||
*/
|
||||
export function getBeatLeaderSessionCookieHeader(cookies: Cookies): string | null {
|
||||
try {
|
||||
const raw = cookies.get(SESSION_COOKIE);
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw) as string[];
|
||||
if (Array.isArray(arr) && arr.length > 0) return arr.join('; ');
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const persisted = readJsonPersistent<{ cookies?: string[] }>('beatleader_session.json');
|
||||
if (persisted?.cookies && persisted.cookies.length > 0) return persisted.cookies.join('; ');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearBeatLeaderSession(cookies: Cookies): void {
|
||||
cookies.delete(SESSION_COOKIE, cookieOptions());
|
||||
try {
|
||||
deletePersistent('beatleader_session.json');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getAccessTokenFromCookies(cookies: Cookies): { token: string | null; expiresAt: number | null } {
|
||||
const token = cookies.get(ACCESS_COOKIE) ?? null;
|
||||
const expiresRaw = cookies.get(EXPIRES_COOKIE);
|
||||
const expiresAt = expiresRaw ? Number(expiresRaw) : null;
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(cookies: Cookies): Promise<string | null> {
|
||||
const refreshToken = cookies.get(REFRESH_COOKIE);
|
||||
let tokenToUse: string | undefined = refreshToken ?? undefined;
|
||||
if (!tokenToUse) {
|
||||
// Try persistent token store
|
||||
const persisted = readJsonPersistent<{ refresh_token?: string | null }>(TOKENS_FILE);
|
||||
tokenToUse = (persisted?.refresh_token ?? undefined) as string | undefined;
|
||||
}
|
||||
if (!tokenToUse) return null;
|
||||
|
||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
||||
const clientId = creds?.client_id;
|
||||
const clientSecret = creds?.client_secret;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: tokenToUse
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
clearTokens(cookies);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await res.json();
|
||||
setTokens(cookies, tokenData);
|
||||
return tokenData.access_token as string;
|
||||
}
|
||||
|
||||
export async function getValidAccessToken(cookies: Cookies): Promise<string | null> {
|
||||
const { token, expiresAt } = getAccessTokenFromCookies(cookies);
|
||||
if (token && expiresAt && Date.now() < expiresAt - 10_000) {
|
||||
return token; // still valid (with 10s skew)
|
||||
}
|
||||
// Try refresh
|
||||
const refreshed = await refreshAccessToken(cookies);
|
||||
if (refreshed) return refreshed;
|
||||
|
||||
// As last resort, if we have a persisted access token that may still be valid, use it
|
||||
const persisted = readJsonPersistent<{ access_token?: string | null; expires_at?: number | null }>(TOKENS_FILE);
|
||||
if (persisted?.access_token && persisted?.expires_at && Date.now() < (persisted.expires_at - 10_000)) {
|
||||
cookies.set(ACCESS_COOKIE, persisted.access_token, { ...cookieOptions(), maxAge: Math.floor((persisted.expires_at - Date.now()) / 1000) });
|
||||
cookies.set(EXPIRES_COOKIE, String(persisted.expires_at), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
||||
return persisted.access_token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl(origin: string, scopes: string[]): URL {
|
||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
||||
const clientId = creds?.client_id;
|
||||
if (!clientId) throw new Error('BeatLeader OAuth is not configured. Visit /tools/beatleader-oauth to set it up.');
|
||||
const redirectUri = `${origin}/auth/beatleader/callback`;
|
||||
const url = new URL('https://api.beatleader.com/oauth2/authorize');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
// BeatLeader expects scopes without the "scp:" prefix in the authorization request
|
||||
const requestedScopes = scopes.map(s => s.replace(/^scp:/, '')).join(' ');
|
||||
url.searchParams.set('scope', requestedScopes);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(origin: string, code: string): Promise<{ access_token: string; refresh_token?: string; expires_in?: number } | null> {
|
||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
||||
const clientId = creds?.client_id;
|
||||
const clientSecret = creds?.client_secret;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
const redirectUri = `${origin}/auth/beatleader/callback`;
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as any;
|
||||
}
|
||||
|
||||
// Persistent storage helpers (single-user local setup)
|
||||
function ensureDataDir(): void {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(name: string): string {
|
||||
return path.join(process.cwd(), DATA_DIR, name);
|
||||
}
|
||||
|
||||
function writeJsonPersistent<T extends object>(name: string, data: T): void {
|
||||
ensureDataDir();
|
||||
fs.writeFileSync(resolvePath(name), JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function readJsonPersistent<T>(name: string): T | null {
|
||||
try {
|
||||
const p = resolvePath(name);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = fs.readFileSync(p, 'utf-8');
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePersistent(name: string): void {
|
||||
try {
|
||||
const p = resolvePath(name);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function storeOAuthCredentials(input: { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] }): void {
|
||||
writeJsonPersistent(CREDS_FILE, input);
|
||||
}
|
||||
|
||||
export function readOAuthCredentials(): { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] } | null {
|
||||
return readJsonPersistent(CREDS_FILE);
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ export interface CuratedSongInfo {
|
||||
hash: string;
|
||||
key: string;
|
||||
songName: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface MapperMapInfo extends CuratedSongInfo {
|
||||
@ -57,7 +58,10 @@ export class BeatSaverAPI {
|
||||
}
|
||||
|
||||
// Public API
|
||||
async getCuratedSongs(useCache: boolean = true): Promise<CuratedSongInfo[]> {
|
||||
async getCuratedSongs(
|
||||
useCache: boolean = true,
|
||||
options: { maxPages?: number; tolerateErrors?: boolean } = {}
|
||||
): Promise<CuratedSongInfo[]> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, 'curated_songs.json');
|
||||
if (useCache) {
|
||||
const cached = await this.readCache<CuratedSongInfo[]>(cachePath);
|
||||
@ -66,11 +70,15 @@ export class BeatSaverAPI {
|
||||
|
||||
const processed: CuratedSongInfo[] = [];
|
||||
let page = 0;
|
||||
const maxPages = options.maxPages ?? undefined;
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ sortOrder: 'Curated', curated: 'true' })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
if (options.tolerateErrors) break;
|
||||
throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
|
||||
}
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
@ -78,7 +86,8 @@ export class BeatSaverAPI {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName
|
||||
songName: song?.metadata?.songName,
|
||||
date: song?.lastPublishedAt ?? song?.uploaded ?? song?.createdAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -86,6 +95,7 @@ export class BeatSaverAPI {
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
if (maxPages !== undefined && page >= maxPages) break;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
@ -296,7 +306,7 @@ export class BeatSaverAPI {
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
return fs.promises as typeof import('fs').promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
@ -253,7 +253,7 @@ export class PlaylistBuilder {
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
return fs.promises as typeof import('fs').promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
@ -103,8 +103,8 @@ export class ScoreSaberAPI {
|
||||
const entries = await fs.readdir(this.cacheDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => fs.unlink(this.pathJoin(this.cacheDir, e.name)))
|
||||
.filter((entry: import('fs').Dirent) => entry.isFile())
|
||||
.map((entry: import('fs').Dirent) => fs.unlink(this.pathJoin(this.cacheDir, entry.name)))
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
@ -216,7 +216,7 @@ export class ScoreSaberAPI {
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
return fs.promises as typeof import('fs').promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
72
src/lib/server/steam.ts
Normal file
72
src/lib/server/steam.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
const OPENID_ENDPOINT = 'https://steamcommunity.com/openid/login';
|
||||
|
||||
const STEAM_ID_COOKIE = 'steam_id64';
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: '/',
|
||||
httpOnly: true as const,
|
||||
sameSite: 'lax' as const,
|
||||
secure: dev ? false : true,
|
||||
maxAge: 30 * 24 * 3600
|
||||
};
|
||||
}
|
||||
|
||||
export function setSteamIdCookie(cookies: Cookies, steamId64: string): void {
|
||||
cookies.set(STEAM_ID_COOKIE, steamId64, cookieOptions());
|
||||
}
|
||||
|
||||
export function getSteamIdFromCookies(cookies: Cookies): string | null {
|
||||
return cookies.get(STEAM_ID_COOKIE) ?? null;
|
||||
}
|
||||
|
||||
export function clearSteamCookie(cookies: Cookies): void {
|
||||
cookies.delete(STEAM_ID_COOKIE, cookieOptions());
|
||||
}
|
||||
|
||||
export function buildSteamOpenIDLoginUrl(origin: string, redirectTo?: string | null): URL {
|
||||
const returnTo = new URL('/auth/steam/callback', origin);
|
||||
if (redirectTo) returnTo.searchParams.set('redirect_uri', redirectTo);
|
||||
|
||||
const realm = origin;
|
||||
|
||||
const url = new URL(OPENID_ENDPOINT);
|
||||
url.searchParams.set('openid.ns', 'http://specs.openid.net/auth/2.0');
|
||||
url.searchParams.set('openid.mode', 'checkid_setup');
|
||||
url.searchParams.set('openid.return_to', returnTo.toString());
|
||||
url.searchParams.set('openid.realm', realm);
|
||||
url.searchParams.set('openid.identity', 'http://specs.openid.net/auth/2.0/identifier_select');
|
||||
url.searchParams.set('openid.claimed_id', 'http://specs.openid.net/auth/2.0/identifier_select');
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function verifySteamOpenIDResponse(searchParams: URLSearchParams): Promise<string | null> {
|
||||
// Steam expects the exact parameters back with mode switched to check_authentication
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (key === 'openid.mode') continue;
|
||||
if (!key.startsWith('openid.')) continue;
|
||||
params.set(key, value);
|
||||
}
|
||||
params.set('openid.mode', 'check_authentication');
|
||||
|
||||
const res = await fetch(OPENID_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
if (!/is_valid\s*:\s*true/.test(text)) return null;
|
||||
|
||||
const claimed = searchParams.get('openid.claimed_id') || '';
|
||||
// Expected format: https://steamcommunity.com/openid/id/7656119...
|
||||
const m = claimed.match(/\/id\/(\d+)$/);
|
||||
if (!m) return null;
|
||||
return m[1];
|
||||
}
|
||||
|
||||
|
||||
@ -46,8 +46,10 @@ function handleTimeUpdate(): void {
|
||||
}
|
||||
|
||||
function handleMetadata(): void {
|
||||
if (!audio) return;
|
||||
update((state) => ({ ...state, duration: isFinite(audio.duration) ? audio.duration : 0 }));
|
||||
const a = audio;
|
||||
if (!a) return;
|
||||
const dur = Number.isFinite(a.duration) ? a.duration : 0;
|
||||
update((state) => ({ ...state, duration: dur }));
|
||||
}
|
||||
|
||||
function handleEnded(): void {
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
</main>
|
||||
<footer class="border-t border-white/10 text-sm text-muted/80">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6 flex items-center justify-between">
|
||||
<span>© {new Date().getFullYear()} Plebsaber.stream</span>
|
||||
<span>© {new Date().getFullYear()} plebsaber.stream</span>
|
||||
<a href="https://svelte.dev" class="hover:underline text-muted">Built with SvelteKit</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const prerender = true;
|
||||
|
||||
export const trailingSlash = 'never';
|
||||
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<section class="grid items-center gap-10 py-12 md:py-20 lg:grid-cols-2">
|
||||
<div class="space-y-6">
|
||||
<h1 class="font-display text-4xl sm:text-5xl lg:text-6xl leading-tight">
|
||||
Beat Saber tools for the <span class="neon-text">cyber</span> underground
|
||||
helper tools for <span class="neon-text">Beat Saber</span>
|
||||
</h1>
|
||||
<p class="max-w-prose text-lg text-muted">
|
||||
Mods, maps, practice helpers and utilities. Tuned for performance. Styled for neon.
|
||||
Notes about how things work, helpers and utilities.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/tools" class="btn-neon">Explore Tools</a>
|
||||
@ -14,42 +14,35 @@
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-6 bg-gradient-to-tr from-neon/20 via-transparent to-neon-fuchsia/20 blur-2xl rounded-3xl"></div>
|
||||
<div class="relative card-surface p-6 sm:p-8">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">PP Calculator</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">Map Search</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">Replay Analyzer</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">Practice Slicer</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative card-surface p-6 sm:p-8">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a href="/tools/beatleader-compare" class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4 block hover:ring-white/20 transition">
|
||||
<div class="text-sm text-muted">Tool</div>
|
||||
<div class="mt-1 text-2xl font-mono">BeatLeader Compare</div>
|
||||
<div class="mt-1 text-sm text-muted">A vs B: songs A played that B has not</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-8">
|
||||
<h2 class="font-display text-2xl tracking-widest text-muted mb-4">Featured tools</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array.from({ length: 6 }) as _, i}
|
||||
<article class="card-surface p-5">
|
||||
<h3 class="font-semibold">Tool {i + 1}</h3>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Coming soon. Have an idea? Open an issue or PR.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<button class="btn-neon">Learn more</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
<section class="py-10">
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<h2 class="font-display tracking-widest">Latest Guides</h2>
|
||||
</div>
|
||||
<div class="not-prose mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a href="/guides/finding-new-songs" class="card-surface p-5 block">
|
||||
<div class="text-sm text-muted">Guide</div>
|
||||
<h3 class="font-semibold mt-1">Finding New Songs (BeatLeader)</h3>
|
||||
<p class="mt-1 text-sm text-muted">Month-by-month search using unranked stars, tech rating, and friend filters.</p>
|
||||
</a>
|
||||
<a href="/guides/beatleader-auth" class="card-surface p-5 block">
|
||||
<div class="text-sm text-muted">Guide</div>
|
||||
<h3 class="font-semibold mt-1">BeatLeader Authentication</h3>
|
||||
<p class="mt-1 text-sm text-muted">Connect BeatLeader and enable unranked stars in tools.</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
@ -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<string, string> | 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<string, string> = {};
|
||||
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<string, string>).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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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: <redacted>\"');
|
||||
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<string, string> = { '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: <redacted>\"');
|
||||
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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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: <redacted>\"');
|
||||
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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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<string, string> = { '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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
40
src/routes/api/beatleader/oauth/creds/+server.ts
Normal file
40
src/routes/api/beatleader/oauth/creds/+server.ts
Normal file
@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
78
src/routes/api/beatleader/oauth/register/+server.ts
Normal file
78
src/routes/api/beatleader/oauth/register/+server.ts
Normal file
@ -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<string, string> = {};
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
24
src/routes/api/beatleader/oauth/status/+server.ts
Normal file
24
src/routes/api/beatleader/oauth/status/+server.ts
Normal file
@ -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' } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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<string, string> | 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<string, string> = {};
|
||||
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<string, string>).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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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 <redacted>\"');
|
||||
if (hasCookieHeader) curlParts.push('-H', '\"Cookie: <redacted>\"');
|
||||
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<string, string> = { '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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
36
src/routes/api/beatleader/user/profile/+server.ts
Normal file
36
src/routes/api/beatleader/user/profile/+server.ts
Normal file
@ -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<string, string> = token ? { Authorization: `Bearer ${token}` } : cookieHeader ? { Cookie: cookieHeader } : {};
|
||||
const res = await fetch(endpoint, { method: 'PATCH', headers: upstreamHeaders });
|
||||
const text = await res.text();
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'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' } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
23
src/routes/api/beatsaver/curated/+server.ts
Normal file
23
src/routes/api/beatsaver/curated/+server.ts
Normal file
@ -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' } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
12
src/routes/api/steam/status/+server.ts
Normal file
12
src/routes/api/steam/status/+server.ts
Normal file
@ -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' } }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
51
src/routes/api/steam/ticket/+server.ts
Normal file
51
src/routes/api/steam/ticket/+server.ts
Normal file
@ -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<string> {
|
||||
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' } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
39
src/routes/auth/beatleader/callback/+server.ts
Normal file
39
src/routes/auth/beatleader/callback/+server.ts
Normal file
@ -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 } });
|
||||
};
|
||||
|
||||
|
||||
27
src/routes/auth/beatleader/login/+server.ts
Normal file
27
src/routes/auth/beatleader/login/+server.ts
Normal file
@ -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() } });
|
||||
};
|
||||
|
||||
|
||||
11
src/routes/auth/beatleader/logout-all/+server.ts
Normal file
11
src/routes/auth/beatleader/logout-all/+server.ts
Normal file
@ -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 } });
|
||||
};
|
||||
|
||||
|
||||
10
src/routes/auth/beatleader/logout/+server.ts
Normal file
10
src/routes/auth/beatleader/logout/+server.ts
Normal file
@ -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 } });
|
||||
};
|
||||
|
||||
|
||||
76
src/routes/auth/beatleader/session/login/+server.ts
Normal file
76
src/routes/auth/beatleader/session/login/+server.ts
Normal file
@ -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' } });
|
||||
};
|
||||
|
||||
|
||||
9
src/routes/auth/beatleader/session/logout/+server.ts
Normal file
9
src/routes/auth/beatleader/session/logout/+server.ts
Normal file
@ -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' } });
|
||||
};
|
||||
|
||||
|
||||
66
src/routes/auth/beatleader/steam-ticket/+server.ts
Normal file
66
src/routes/auth/beatleader/steam-ticket/+server.ts
Normal file
@ -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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
15
src/routes/auth/steam/callback/+server.ts
Normal file
15
src/routes/auth/steam/callback/+server.ts
Normal file
@ -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 } });
|
||||
};
|
||||
|
||||
|
||||
10
src/routes/auth/steam/login/+server.ts
Normal file
10
src/routes/auth/steam/login/+server.ts
Normal file
@ -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() } });
|
||||
};
|
||||
|
||||
|
||||
10
src/routes/auth/steam/logout/+server.ts
Normal file
10
src/routes/auth/steam/logout/+server.ts
Normal file
@ -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 } });
|
||||
};
|
||||
|
||||
|
||||
@ -3,19 +3,14 @@
|
||||
<p>Community-written tips and guides for improving your Beat Saber game. Contributions welcome.</p>
|
||||
|
||||
<div class="not-prose grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mt-6">
|
||||
{#each [
|
||||
'Setup & Mods',
|
||||
'Finding Great Maps',
|
||||
'Improving Accuracy',
|
||||
'Fitness & Endurance',
|
||||
'Controller Settings',
|
||||
'Troubleshooting'
|
||||
] as title}
|
||||
<article class="card-surface p-5">
|
||||
<h3 class="font-semibold">{title}</h3>
|
||||
<p class="mt-1 text-sm text-muted">Draft</p>
|
||||
</article>
|
||||
{/each}
|
||||
<a href="/guides/beatleader-auth" class="card-surface p-5 block">
|
||||
<h3 class="font-semibold">BeatLeader Authentication</h3>
|
||||
<p class="mt-1 text-sm text-muted">Connect BeatLeader to enhance tools like Compare Players.</p>
|
||||
</a>
|
||||
<a href="/guides/finding-new-songs" class="card-surface p-5 block">
|
||||
<h3 class="font-semibold">Finding New Songs (BeatLeader)</h3>
|
||||
<p class="mt-1 text-sm text-muted">Month-by-month search using unranked stars, tech rating, and friend filters.</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
284
src/routes/guides/beatleader-auth/+page.svelte
Normal file
284
src/routes/guides/beatleader-auth/+page.svelte
Normal file
@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hasSteam: boolean = false;
|
||||
let steamId: string | null = null;
|
||||
let oauthConnected: boolean = false;
|
||||
let hasCreds: boolean = false;
|
||||
let hasSession: boolean = false;
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/beatleader/oauth/status');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
oauthConnected = Boolean(data?.connected);
|
||||
hasCreds = Boolean(data?.hasCreds);
|
||||
hasSession = Boolean(data?.hasSession);
|
||||
hasSteam = Boolean(data?.hasSteam);
|
||||
steamId = (data?.steamId ?? null) as string | null;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
onMount(loadStatus);
|
||||
</script>
|
||||
|
||||
<section class="py-8 prose prose-invert max-w-none">
|
||||
<h1 class="font-display tracking-widest">BeatLeader Authentication</h1>
|
||||
<div class="not-prose rounded border border-yellow-500/40 bg-yellow-500/10 text-yellow-200 px-3 py-2 mt-2 text-sm">
|
||||
Educational use only: The information and resources on this page are for learning purposes. Do not use them for real authentication or accessing accounts.
|
||||
</div>
|
||||
<p>
|
||||
This app supports three ways to access your BeatLeader data: Steam, OAuth, and a website‑style session.
|
||||
</p>
|
||||
|
||||
<!-- Top navigation -->
|
||||
<nav class="not-prose mt-4 flex flex-wrap gap-2">
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#steam">Steam</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#oauth">OAuth</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#session">Website Session</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#setup">OAuth App Setup</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#unranked">Unranked Stars</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#usage">How It’s Used</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#sign-out">Sign out</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#troubleshooting">Troubleshooting</a>
|
||||
</nav>
|
||||
|
||||
<!-- Quick actions moved to tool page; link instead -->
|
||||
<div class="not-prose mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a class="btn-neon" href="/tools/beatleader-auth">Open BeatLeader Auth Tool</a>
|
||||
</div>
|
||||
|
||||
<!-- Current status summary -->
|
||||
<div class="not-prose mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="rounded border border-white/10 p-3">
|
||||
<div class="text-sm">Steam</div>
|
||||
<div class="text-xs text-muted mt-1">{hasSteam ? `Linked (${steamId})` : 'Not linked'}</div>
|
||||
{#if hasSteam}
|
||||
<form method="POST" action="/auth/steam/logout" class="mt-2">
|
||||
<button class="rounded bg-white/10 px-2 py-1 text-xs hover:bg-white/20">Sign out of Steam</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="rounded border border-white/10 p-3">
|
||||
<div class="text-sm">OAuth</div>
|
||||
<div class="text-xs text-muted mt-1">{oauthConnected ? 'Connected' : 'Not connected'}</div>
|
||||
{#if oauthConnected}
|
||||
<form method="POST" action="/auth/beatleader/logout" class="mt-2">
|
||||
<button class="rounded bg-white/10 px-2 py-1 text-xs hover:bg-white/20">Sign out of OAuth</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="rounded border border-white/10 p-3">
|
||||
<div class="text-sm">Website Session</div>
|
||||
<div class="text-xs text-muted mt-1">{hasSession ? 'Active (captured via Steam)' : 'Not active'}</div>
|
||||
{#if hasSession}
|
||||
<form on:submit|preventDefault={async () => { await fetch('/auth/beatleader/session/logout', { method: 'POST' }); await loadStatus(); }} class="mt-2">
|
||||
<button class="rounded bg-white/10 px-2 py-1 text-xs hover:bg-white/20">Clear Session</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-sm text-muted">
|
||||
Default API auth is <strong>Steam</strong>. You can override per request using <code>?auth=steam|oauth|session|auto|none</code>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Tools like <a href="/tools/beatleader-compare">Compare Players</a>
|
||||
can show unranked star ratings when your BeatLeader account is a supporter and <code>ShowAllRatings</code> is enabled.
|
||||
</p>
|
||||
|
||||
<h2 id="steam">Steam Login</h2>
|
||||
<p>
|
||||
Authenticate via Steam OpenID to link your Steam account. Then use the <a href="/tools/beatleader-auth">BeatLeader Auth Tool</a> with your Steam session ticket to capture a website session.
|
||||
</p>
|
||||
|
||||
<h2 id="oauth">OAuth Login</h2>
|
||||
<ul>
|
||||
<li><code>scp:profile</code> to read your user profile.</li>
|
||||
<li><code>scp:offline_access</code> to refresh your session without re‑prompting (optional).</li>
|
||||
</ul>
|
||||
<p>
|
||||
You can still login with OAuth when needed: <a class="btn-neon" href="/auth/beatleader/login?redirect_uri=%2Ftools%2Fbeatleader-compare">Login with BeatLeader (OAuth)</a>
|
||||
</p>
|
||||
<p>
|
||||
After OAuth login, we attempt to enable <code>ShowAllRatings</code> automatically. If your BL account is not a supporter, BL will ignore it.
|
||||
</p>
|
||||
|
||||
<h2 id="session">Session Login (Website‑style)</h2>
|
||||
<p>
|
||||
Most users authenticate on the BeatLeader website using Steam. Use the <a href="/tools/beatleader-auth">BeatLeader Auth Tool</a> to submit a Steam session ticket; we’ll create a BL website session for server‑side calls where OAuth is not honored.
|
||||
</p>
|
||||
<p>
|
||||
The current session status is available via <code>GET /api/beatleader/oauth/status</code> as <code>hasSession</code>.
|
||||
</p>
|
||||
|
||||
<h2 id="unranked">Requirements to see unranked star ratings</h2>
|
||||
<ul>
|
||||
<li>You must authenticate with a user identity that BeatLeader recognizes for profile flags like <code>ShowAllRatings</code>. For the endpoints listed below, this currently means a website session cookie, not an OAuth bearer.</li>
|
||||
<li>Your BeatLeader account must be a supporter (tipper/supporter/sponsor).</li>
|
||||
<li>Your BL profile setting <code>ShowAllRatings</code> must be enabled. We auto‑enable it after login; if you're not a supporter, BL will ignore it.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="oauth-vs-session">OAuth tokens vs. website session for scores/stars</h3>
|
||||
<p>
|
||||
Some read endpoints on BeatLeader currently do <strong>not</strong> honor OAuth bearer tokens for determining the current user, and instead rely on the cookie‑authenticated website session:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>GET /player/{id}/scores</code></li>
|
||||
<li><code>GET /leaderboards/hash/{hash}</code></li>
|
||||
</ul>
|
||||
<p>
|
||||
For these endpoints, an OAuth <code>Authorization: Bearer ...</code> header will not affect user‑specific visibility like <code>ShowAllRatings</code>. They read the user from the website session cookie. In our debug panel this appears as <em>Using auth: oauth</em> with <em>Last Cookie: absent</em> — and unranked stars may be hidden even if your account is eligible.
|
||||
</p>
|
||||
<p><strong>Workarounds</strong></p>
|
||||
<ul>
|
||||
<li><strong>Use a website session</strong>: Use the <a href="/tools/beatleader-auth">BeatLeader Auth Tool</a> with your Steam session ticket. We store the BL session cookie and attach it to server calls. Debug will show <em>Using auth: session</em> and <em>Last Cookie: present</em>.</li>
|
||||
<li><strong>Force session for a request</strong>: Append <code>?auth=session</code> to our API URLs to prefer website session over OAuth for that call. Example: <code>/api/beatleader?path=/leaderboards/hash/{hash}&auth=session</code>.</li>
|
||||
<li><strong>Prefer session when both exist</strong>: Our tools default to <code>?auth=auto</code> (use OAuth if present, then session). If you have both and need session for these endpoints, explicitly pass <code>?auth=session</code> or temporarily sign out OAuth at <a href="/auth/beatleader/logout">/auth/beatleader/logout</a>.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Other endpoints (e.g., <code>/oauth2/identity</code>, 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.
|
||||
</p>
|
||||
|
||||
<h2 id="setup">OAuth App Setup (for developers)</h2>
|
||||
<ol>
|
||||
<li>
|
||||
Configure BeatLeader OAuth credentials here: <a href="/tools/beatleader-oauth">BL OAuth Setup</a>.
|
||||
If you already have credentials, you can paste them there.
|
||||
</li>
|
||||
<li>
|
||||
Use the OAuth Login above; we request <code>scp:offline_access</code> to allow refresh without re‑prompting.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="usage">How this app uses your auth</h2>
|
||||
<ul>
|
||||
<li>Default auth mode is <strong>Steam</strong>. You can override per‑request with <code>?auth=steam|oauth|session|auto|none</code>.</li>
|
||||
<li>Server routes use your OAuth token when available; otherwise some endpoints fall back to your BL session cookie:</li>
|
||||
<ul>
|
||||
<li><code>/api/beatleader?path=/user</code> supports OAuth token or session cookie.</li>
|
||||
<li><code>/api/beatleader/user/profile</code> (PATCH) uses OAuth token or session cookie to toggle flags like <code>ShowAllRatings</code>.</li>
|
||||
<li>Other public GETs are proxied unauthenticated or with your token when relevant.</li>
|
||||
</ul>
|
||||
<li>Other tools call these endpoints and will include unranked stars if your user auth allows it and <code>ShowAllRatings</code> is enabled.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="desktop">Desktop app (Tauri) implementation plan</h2>
|
||||
<p>
|
||||
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:
|
||||
</p>
|
||||
<ol>
|
||||
<li><strong>Integrate Steamworks in Tauri</strong>: add Steamworks SDK and implement a Tauri command (e.g., <code>getSteamSessionTicket</code>) that calls <code>ISteamUser.GetAuthSessionTicket</code> and returns the ticket string.</li>
|
||||
<li><strong>Wire the ticket endpoint</strong>: make <code>GET /api/steam/ticket</code> call the Tauri command. Until Tauri is in place, it supports a local helper via <code>BL_STEAM_TICKET_CMD</code> that outputs a ticket to stdout.</li>
|
||||
<li><strong>Create BL website session</strong>: the tool at <a href="/tools/beatleader-auth">BeatLeader Auth Tool</a> fetches the Steam ticket and then POSTs it to <code>/auth/beatleader/steam-ticket</code> (forwards to <code>api.beatleader.com/signin</code> with <code>provider=steamTicket</code>). We capture BL cookies server‑side.</li>
|
||||
<li><strong>Verify</strong>: <code>GET /api/beatleader/oauth/status</code> should report <code>hasSession: true</code>.</li>
|
||||
<li><strong>Use session for specific endpoints</strong>: For <code>/player/{id}/scores</code> and <code>/leaderboards/hash/{hash}</code> (where OAuth is ignored), call our proxy with <code>?auth=session</code> or keep <code>?auth=auto</code> when no OAuth token is present.</li>
|
||||
</ol>
|
||||
<p class="text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Why connect?</h2>
|
||||
<ul>
|
||||
<li>Compare Players can show BeatLeader star ratings alongside map tiles.</li>
|
||||
<li>If your BL account grants visibility into unranked stars, those will appear too.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Technical Details</h2>
|
||||
<p>
|
||||
For developers curious about the implementation, here's how BeatLeader's OAuth2.0 system works:
|
||||
</p>
|
||||
|
||||
<h3>BeatLeader Server (Backend)</h3>
|
||||
<p>
|
||||
BeatLeader uses <strong>OpenIddict</strong> as its OAuth2.0/OpenID Connect server implementation. The token creation is handled automatically by OpenIddict rather than custom code.
|
||||
</p>
|
||||
|
||||
<h4>Key Components:</h4>
|
||||
<ul>
|
||||
<li><strong>Token Endpoint:</strong> <code>/oauth2/token</code> - Handled automatically by OpenIddict</li>
|
||||
<li><strong>Authorization Endpoint:</strong> <code>/oauth2/authorize</code> - Custom controller in <code>AuthenticationController.cs</code></li>
|
||||
<li><strong>User Info Endpoint:</strong> <code>/oauth2/identity</code> - Returns user profile data</li>
|
||||
<li><strong>Client Management:</strong> <code>/developer/app*</code> - OAuth application registration and management</li>
|
||||
</ul>
|
||||
|
||||
<h4>Authorization Flow:</h4>
|
||||
<ol>
|
||||
<li><strong>GET /oauth2/authorize:</strong> Renders consent screen and validates user authentication</li>
|
||||
<li><strong>POST /oauth2/authorize:</strong> Processes user consent and creates authorization code via <code>SignIn()</code></li>
|
||||
<li><strong>POST /oauth2/token:</strong> OpenIddict automatically exchanges authorization code for access/refresh tokens</li>
|
||||
</ol>
|
||||
|
||||
<h4>Configuration (Startup.cs):</h4>
|
||||
<pre><code>options.SetAuthorizationEndpointUris("oauth2/authorize")
|
||||
.SetTokenEndpointUris("oauth2/token")
|
||||
.SetUserinfoEndpointUris("oauth2/identity");
|
||||
|
||||
options.AllowAuthorizationCodeFlow()
|
||||
.AllowRefreshTokenFlow();</code></pre>
|
||||
|
||||
<h3>BeatLeader Website (Frontend)</h3>
|
||||
<p>
|
||||
The website provides the OAuth consent UI and developer portal, but delegates all token operations to the API.
|
||||
</p>
|
||||
|
||||
<h4>Key Components:</h4>
|
||||
<ul>
|
||||
<li><strong>Consent Screen:</strong> <code>OauthSignIn.svelte</code> - Renders the authorization approval UI</li>
|
||||
<li><strong>Developer Portal:</strong> <code>DeveloperPortal.svelte</code> - OAuth application management</li>
|
||||
<li><strong>App Management:</strong> <code>OauthApp.svelte</code> - Create/edit OAuth applications</li>
|
||||
</ul>
|
||||
|
||||
<h4>Authorization Flow:</h4>
|
||||
<ol>
|
||||
<li><strong>Consent UI:</strong> Posts to <code>BL_API_URL + 'oauth2/authorize'</code> with user approval</li>
|
||||
<li><strong>Client Info:</strong> Fetches app details from <code>BL_API_URL + 'oauthclient/info'</code></li>
|
||||
<li><strong>CSRF Protection:</strong> Uses antiforgery tokens from <code>BL_API_URL + 'oauthclient/antiforgery'</code></li>
|
||||
</ol>
|
||||
|
||||
<h4>Developer Portal Features:</h4>
|
||||
<ul>
|
||||
<li>OAuth application registration with custom client IDs</li>
|
||||
<li>Scope management (profile, clan, offline_access)</li>
|
||||
<li>Redirect URL configuration</li>
|
||||
<li>Client secret generation and reset</li>
|
||||
<li>Test authorization URL generation</li>
|
||||
</ul>
|
||||
|
||||
<h3>OAuth Flow Summary</h3>
|
||||
<ol>
|
||||
<li>Client redirects user to <code>/oauth2/authorize</code> with client_id, scope, response_type=code</li>
|
||||
<li>BeatLeader validates user authentication and renders consent screen</li>
|
||||
<li>User approves → authorization code generated via OpenIddict</li>
|
||||
<li>Client exchanges code for tokens at <code>/oauth2/token</code> (handled by OpenIddict)</li>
|
||||
<li>Client uses access token for API calls, refresh token for renewal</li>
|
||||
</ol>
|
||||
|
||||
<h3>Security Features</h3>
|
||||
<ul>
|
||||
<li><strong>CSRF Protection:</strong> Antiforgery tokens on consent forms</li>
|
||||
<li><strong>Scope Validation:</strong> Only requested scopes are granted</li>
|
||||
<li><strong>Client Validation:</strong> Registered applications only</li>
|
||||
<li><strong>Token Security:</strong> Signed/encrypted tokens with X.509 certificates</li>
|
||||
<li><strong>Authorization Storage:</strong> Permanent authorizations to avoid repeated consent</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="sign-out">Sign out</h2>
|
||||
<ul>
|
||||
<li><strong>Everything:</strong> <code>POST /auth/beatleader/logout-all</code> (clears OAuth tokens and session)</li>
|
||||
<li><strong>Only OAuth tokens:</strong> visit <code>/auth/beatleader/logout</code></li>
|
||||
<li><strong>Only session cookie:</strong> <code>POST /auth/beatleader/session/logout</code></li>
|
||||
</ul>
|
||||
<pre><code>curl -X POST 'https://{your-host}/auth/beatleader/logout-all'</code></pre>
|
||||
|
||||
<h2 id="troubleshooting">Troubleshooting</h2>
|
||||
<ul>
|
||||
<li>Unranked stars missing: confirm you’re logged in with a supporter BL account and that <code>ShowAllRatings</code> is enabled.</li>
|
||||
<li>Check connection: <code>GET /api/beatleader/oauth/status</code> shows <code>connected</code>, <code>hasCreds</code>, and <code>hasSession</code>.</li>
|
||||
<li>Re‑login: use OAuth at <code>/auth/beatleader/login</code> or submit a fresh Steam session ticket in the <a href="/tools/beatleader-auth">BeatLeader Auth Tool</a>.</li>
|
||||
<li>If the debug panel shows <em>Using auth: oauth</em> and <em>Last Cookie: absent</em> on <code>/player/{id}/scores</code> or <code>/leaderboards/hash/{hash}</code>, force session with <code>?auth=session</code> and ensure your website session is active via the tool.</li>
|
||||
<li>Redirect mismatch: ensure your app’s redirect in BL developer portal matches <code>{origin}/auth/beatleader/callback</code>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
78
src/routes/guides/finding-new-songs/+page.svelte
Normal file
78
src/routes/guides/finding-new-songs/+page.svelte
Normal file
@ -0,0 +1,78 @@
|
||||
<section class="py-8 prose prose-invert max-w-none">
|
||||
<h1 class="font-display tracking-widest">Finding New Songs with BeatLeader</h1>
|
||||
<p>
|
||||
A fast, repeatable workflow to discover fresh maps using BeatLeader’s powerful search. It relies on
|
||||
supporter‑only unranked star ratings (<code>ShowAllRatings</code>) and sorts by <strong>techRating</strong> to surface interesting patterns.
|
||||
</p>
|
||||
|
||||
<nav class="not-prose mt-4 flex flex-wrap gap-2">
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#requirements">Requirements</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#search-setup">Search setup</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#workflow">Monthly workflow</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#shortcuts">Shortcuts</a>
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="#tips">Tips</a>
|
||||
</nav>
|
||||
|
||||
<h2 id="requirements">Requirements</h2>
|
||||
<ul>
|
||||
<li><strong>BeatLeader supporter</strong> role on your BL account (Patreon/tipper/supporter/sponsor).</li>
|
||||
<li><strong><code>ShowAllRatings</code></strong> enabled on your BL profile. See <a href="/guides/beatleader-auth">BeatLeader Authentication</a> for how to sign in and enable it.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="search-setup">Search setup</h2>
|
||||
<p>Use the BeatLeader maps search with these filters:</p>
|
||||
<ul>
|
||||
<li><strong>Date range</strong>: pick a single month (e.g., Dec 1 → Jan 1). Iterate month by month.</li>
|
||||
<li><strong>Stars</strong>: set a band to control density and overall difficulty (e.g., <strong>8–10</strong> for fun, <strong>6–8</strong> for warm‑ups).</li>
|
||||
<li><strong>Sort by</strong>: <strong>techRating</strong>.</li>
|
||||
<li><strong>Mode / Difficulty</strong>: <strong>Standard</strong>, <strong>ExpertPlus</strong> (adjust for your preference).</li>
|
||||
</ul>
|
||||
|
||||
<div class="not-prose mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a class="btn-neon" href="https://beatleader.com/maps/all?stars_from=8&stars_to=10&sortBy=techRating&mode=Standard&difficulty=ExpertPlus" target="_blank" rel="noreferrer">Open BL search (8–10★, techRating)</a>
|
||||
<a class="btn-neon" href="https://beatleader.com/maps/all/2?stars_from=8&stars_to=10&date_from=1735718400&date_to=1738396800&sortBy=techRating&mode=Standard&difficulty=ExpertPlus" target="_blank" rel="noreferrer">Example: Dec 2024 window</a>
|
||||
</div>
|
||||
<p class="text-xs text-muted mt-1">Note: The example uses a specific month via <code>date_from</code>/<code>date_to</code>. Use the BL UI date picker to select your current month.</p>
|
||||
|
||||
<h2 id="workflow">Monthly workflow</h2>
|
||||
<ol>
|
||||
<li>Pick a month and set the date filter to that exact window.</li>
|
||||
<li>Set stars to your target band (e.g., 8–10★). Leave <em>ranked</em> off so unranked stars show.</li>
|
||||
<li>Sort by <strong>techRating</strong>. You should see ~100 results for a typical month.</li>
|
||||
<li>Quick‑scan the list:
|
||||
<ul>
|
||||
<li>The very top often contains maps with ultra‑weird patterns.</li>
|
||||
<li>The bottom often contains very simple/boring patterns.</li>
|
||||
<li>The juicy middle (~70–80% of results) is usually worth considering.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>For each candidate: preview audio, check the mapper, and watch replays to gauge fun factor and pattern quality.</li>
|
||||
<li>Repeat for the next month.</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="collecting">Collecting and downloading maps</h2>
|
||||
<ul>
|
||||
<li><strong>Download as you go</strong>: When you find a keeper, download the map immediately.</li>
|
||||
<li><strong>Playlist workflow</strong>: Use BeatLeader’s <em>Add to playlist</em> 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.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="shortcuts">Shortcuts</h2>
|
||||
<ul>
|
||||
<li><strong>Friends‑played filter</strong>: add <code>mytype=friendsPlayed</code> to show maps your followed players have played. This turns your follow list into a taste‑making filter.</li>
|
||||
</ul>
|
||||
<div class="not-prose mt-2">
|
||||
<a class="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" href="https://beatleader.com/maps/all?stars_from=8&stars_to=10&sortBy=techRating&mode=Standard&difficulty=ExpertPlus&mytype=friendsPlayed" target="_blank" rel="noreferrer">Open 8–10★ + friendsPlayed</a>
|
||||
</div>
|
||||
|
||||
<h2 id="tips">Why this works</h2>
|
||||
<ul>
|
||||
<li><strong>Stars banding</strong> sets expectations for note density and cumulative pattern intensity.</li>
|
||||
<li><strong>Tech rating sort</strong> floats unusual patterning to the top and simpler maps to the bottom, leaving a large middle of promising, fun charts.</li>
|
||||
<li><strong>Monthly windows</strong> keep the set manageable and ensure you don’t miss new drops.</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-sm text-muted">Tip: Create two tabs — one for 8–10★ “fun picks” and another for 6–8★ warmups — and work month by month.</p>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
@ -5,10 +5,7 @@
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#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}
|
||||
<a href={tool.href} class="card-surface p-5 block">
|
||||
<div class="font-semibold">{tool.name}</div>
|
||||
@ -17,12 +14,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="sr-only">
|
||||
<div id="pp"></div>
|
||||
<div id="search"></div>
|
||||
<div id="replay"></div>
|
||||
<div id="slicer"></div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
116
src/routes/tools/beatleader-auth/+page.svelte
Normal file
116
src/routes/tools/beatleader-auth/+page.svelte
Normal file
@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hasSession = false;
|
||||
let steamTicket = '';
|
||||
let busy = false;
|
||||
let message: string | null = null;
|
||||
let helperConfigured = false;
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch('/api/beatleader/oauth/status');
|
||||
const data = res.ok ? await res.json() : {};
|
||||
hasSession = Boolean(data?.hasSession);
|
||||
} catch { hasSession = false; }
|
||||
}
|
||||
async function getTicketFromHelper() {
|
||||
message = null; busy = true;
|
||||
try {
|
||||
const res = await fetch('/api/steam/ticket');
|
||||
const data = await res.json();
|
||||
if (res.ok && data?.ticket) {
|
||||
steamTicket = data.ticket;
|
||||
message = 'Ticket fetched from Steam';
|
||||
} else {
|
||||
message = data?.error || 'Failed to fetch Steam ticket';
|
||||
}
|
||||
} catch {
|
||||
message = 'Failed to fetch Steam ticket';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function submitTicket() {
|
||||
message = null; busy = true;
|
||||
try {
|
||||
const res = await fetch('/auth/beatleader/steam-ticket', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ticket: steamTicket.trim() })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data?.ok) {
|
||||
message = data?.error || 'Failed to create session';
|
||||
} else {
|
||||
message = 'Session captured!';
|
||||
await refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
message = 'Network error';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function clearSession() {
|
||||
busy = true; message = null;
|
||||
await fetch('/auth/beatleader/session/logout', { method: 'POST' });
|
||||
await refresh(); busy = false;
|
||||
}
|
||||
onMount(async () => {
|
||||
await refresh();
|
||||
// Detect helper availability (501 => not configured)
|
||||
try {
|
||||
const res = await fetch('/api/steam/ticket');
|
||||
const data = await res.json();
|
||||
helperConfigured = Boolean(res.ok && data?.ticket) || res.status !== 501;
|
||||
} catch {
|
||||
helperConfigured = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="py-8 prose prose-invert max-w-none">
|
||||
<h1 class="font-display tracking-widest">BeatLeader Authentication</h1>
|
||||
<p>Establish a BeatLeader website session using your Steam session ticket. This mimics how the BeatLeader mod logs in.</p>
|
||||
|
||||
<div class="not-prose my-4 grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded border border-white/10 p-4">
|
||||
<div class="text-sm text-muted">Website Session</div>
|
||||
<div class="mt-1">{hasSession ? 'Active (captured via Steam)' : 'Not active'}</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="btn-neon" on:click={refresh} disabled={busy}>Refresh</button>
|
||||
{#if hasSession}
|
||||
<button class="rounded bg-white/10 px-3 py-1 hover:bg-white/20" on:click={clearSession} disabled={busy}>Clear Session</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded border border-white/10 p-4">
|
||||
<div class="text-sm text-muted">Steam Session Ticket</div>
|
||||
<p class="text-xs text-muted mt-1">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.</p>
|
||||
<div class="mt-2 flex gap-2 items-center">
|
||||
<button class="rounded bg-white/10 px-3 py-1 hover:bg-white/20" on:click={getTicketFromHelper} disabled={busy}>
|
||||
Get Ticket from Steam {#if !helperConfigured}<span class="text-[10px] text-muted">(helper unavailable)</span>{/if}
|
||||
</button>
|
||||
{#if steamTicket}
|
||||
<span class="text-xs text-muted">Ticket fetched</span>
|
||||
{/if}
|
||||
</div>
|
||||
<textarea class="mt-2 w-full rounded bg-white/5 p-2 text-sm" rows="5" bind:value={steamTicket} placeholder="Paste steam session ticket..."></textarea>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<button class="btn-neon" on:click={submitTicket} disabled={busy || !steamTicket.trim()}>Create Session</button>
|
||||
{#if message}
|
||||
<span class="text-xs text-muted">{message}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>How it works</h2>
|
||||
<ul>
|
||||
<li>We POST your ticket to BeatLeader <code>/signin</code> with <code>provider=steamTicket</code>.</li>
|
||||
<li>BeatLeader sets website cookies; we store those server‑side and attach them to API calls that require a website session.</li>
|
||||
<li>Use <code>?auth=session</code> on our API where OAuth is ignored by BL (e.g., scores/leaderboards that show unranked stars).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@ -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[] = [];
|
||||
@ -71,6 +71,17 @@
|
||||
};
|
||||
let metaByHash: Record<string, MapMeta> = {};
|
||||
|
||||
type StarInfo = {
|
||||
stars?: number;
|
||||
accRating?: number;
|
||||
passRating?: number;
|
||||
techRating?: number;
|
||||
status?: number;
|
||||
};
|
||||
// Keyed by `${hash}|${difficultyName}|${modeName}` for precise lookup
|
||||
let starsByKey: Record<string, StarInfo> = {};
|
||||
let loadingStars = false;
|
||||
|
||||
async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
||||
@ -100,6 +111,56 @@
|
||||
loadingMeta = false;
|
||||
}
|
||||
|
||||
async function fetchBeatLeaderStarsByHash(hash: string): Promise<void> {
|
||||
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<void> {
|
||||
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';
|
||||
if (typeof value === 'string') {
|
||||
@ -163,6 +224,25 @@
|
||||
return all;
|
||||
}
|
||||
|
||||
async function fetchAllScoresAnyTime(playerId: string, maxPages = 100): Promise<BeatLeaderScore[]> {
|
||||
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<string, string[]> {
|
||||
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<string>();
|
||||
const bLeaderboardIds = new Set<string>();
|
||||
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 @@
|
||||
</script>
|
||||
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: A vs B — Played‑Only Delta</h1>
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Compare Players</h1>
|
||||
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — last 12 months.</p>
|
||||
|
||||
|
||||
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onCompare}>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Player A ID (source)</label>
|
||||
<input class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerA} placeholder="7656119... or BL ID" required />
|
||||
<label class="block text-sm text-muted">Player A ID (source)
|
||||
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerA} placeholder="7656119... or BL ID" required />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Player B ID (target)</label>
|
||||
<input class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerB} placeholder="7656119... or BL ID" required />
|
||||
<label class="block text-sm text-muted">Player B ID (target)
|
||||
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerB} placeholder="7656119... or BL ID" required />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Song count</label>
|
||||
<input class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="1" max="200" bind:value={songCount} />
|
||||
<label class="block text-sm text-muted">Song count
|
||||
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="1" max="200" bind:value={songCount} />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-neon" disabled={loading}>
|
||||
@ -381,6 +473,9 @@
|
||||
{#if loadingMeta}
|
||||
<div class="mt-2 text-xs text-muted">Loading covers…</div>
|
||||
{/if}
|
||||
{#if loadingStars}
|
||||
<div class="mt-2 text-xs text-muted">Loading star ratings…</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each pageItems as item}
|
||||
@ -406,10 +501,20 @@
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between text-[11px]">
|
||||
<span class="rounded bg-white/10 px-2 py-0.5">
|
||||
{item.difficulties[0]?.characteristic ?? 'Standard'} · {item.difficulties[0]?.name}
|
||||
{item.difficulties[0]?.characteristic ?? 'Standard'} ·
|
||||
<span class="rounded px-1 ml-1" style="background-color: {difficultyToColor(item.difficulties[0]?.name)}; color: #fff;">
|
||||
{item.difficulties[0]?.name}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-muted">{new Date(item.timeset * 1000).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{#if starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars}
|
||||
<div class="mt-1 text-xs">
|
||||
{#key `${item.hash}|${item.difficulties[0]?.name}|${item.difficulties[0]?.characteristic}`}
|
||||
<span title="BeatLeader star rating">★ {starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars?.toFixed(2)}</span>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
<SongPlayer hash={item.hash} preferBeatLeader={true} />
|
||||
</div>
|
||||
@ -433,10 +538,11 @@
|
||||
>BSR</a
|
||||
>
|
||||
<button
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
on:click={() => navigator.clipboard.writeText(item.hash)}
|
||||
title="Copy hash"
|
||||
>Copy hash</button>
|
||||
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</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
283
src/routes/tools/beatleader-oauth/+page.svelte
Normal file
283
src/routes/tools/beatleader-oauth/+page.svelte
Normal file
@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let name = '';
|
||||
let clientId = '';
|
||||
let origin = '';
|
||||
let redirectUrls = '';
|
||||
let scopes: string[] = ['scp:profile', 'scp:offline_access'];
|
||||
let iconFile: File | null = null;
|
||||
let message: string | null = null;
|
||||
let created: any = null;
|
||||
let existing: { client_id: string; client_secret: string } | null = null;
|
||||
let manualClientId = '';
|
||||
let manualClientSecret = '';
|
||||
let recommendedClientId = generateDefaultClientId();
|
||||
let copying: string | null = null;
|
||||
let returnTo: string | null = null;
|
||||
let blCredsStatus: 'unknown' | 'configured' | 'missing' = 'unknown';
|
||||
let blManageUrl: string | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
try { origin = location.origin; } catch {}
|
||||
try { redirectUrls = origin + '/auth/beatleader/callback'; } catch {}
|
||||
}
|
||||
try {
|
||||
const sp = new URLSearchParams(location.search);
|
||||
const ret = sp.get('return');
|
||||
if (ret) returnTo = ret;
|
||||
} catch {}
|
||||
try {
|
||||
const redirect = location.pathname + (location.search || '');
|
||||
blManageUrl = `/tools/beatleader-oauth?return=${encodeURIComponent(redirect)}`;
|
||||
const statusRes = await fetch('/api/beatleader/oauth/status');
|
||||
if (statusRes.ok) {
|
||||
const st: any = await statusRes.json();
|
||||
blCredsStatus = st?.hasCreds ? 'configured' : 'missing';
|
||||
} else {
|
||||
blCredsStatus = 'unknown';
|
||||
}
|
||||
} catch {
|
||||
blCredsStatus = 'unknown';
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/beatleader/oauth/creds');
|
||||
if (res.ok) {
|
||||
const data: any = await res.json();
|
||||
const hasId = typeof data?.client_id === 'string' && data.client_id.length > 0;
|
||||
const hasSecret = typeof data?.client_secret === 'string' && data.client_secret.length > 0;
|
||||
existing = hasId && hasSecret ? { client_id: data.client_id, client_secret: data.client_secret } : null;
|
||||
} else {
|
||||
existing = null;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function onIconChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const f = input.files?.[0] ?? null;
|
||||
if (!f) {
|
||||
iconFile = null;
|
||||
return;
|
||||
}
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||
if (!validTypes.includes(f.type)) {
|
||||
message = 'Icon must be JPG, PNG, or GIF';
|
||||
iconFile = null;
|
||||
return;
|
||||
}
|
||||
if (f.size > 5 * 1024 * 1024) {
|
||||
message = 'Icon must be under 5MB';
|
||||
iconFile = null;
|
||||
return;
|
||||
}
|
||||
message = null;
|
||||
iconFile = f;
|
||||
}
|
||||
|
||||
async function saveManual(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
message = null;
|
||||
try {
|
||||
const res = await fetch('/api/beatleader/oauth/creds', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: manualClientId, client_secret: manualClientSecret })
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
existing = { client_id: manualClientId, client_secret: manualClientSecret };
|
||||
manualClientId = '';
|
||||
manualClientSecret = '';
|
||||
message = 'Saved credentials.';
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : 'Failed to save credentials';
|
||||
}
|
||||
}
|
||||
|
||||
async function register(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
message = null;
|
||||
created = null;
|
||||
try {
|
||||
// Perform registration directly against BeatLeader using browser cookies (must be logged in on beatleader.com)
|
||||
const url = new URL('https://api.beatleader.com/developer/app');
|
||||
url.searchParams.set('name', name);
|
||||
url.searchParams.set('clientId', clientId || generateClientId());
|
||||
url.searchParams.set('scopes', scopes.join(','));
|
||||
url.searchParams.set('redirectUrls', redirectUrls);
|
||||
|
||||
let blRes: Response;
|
||||
if (iconFile) {
|
||||
const buf = await iconFile.arrayBuffer();
|
||||
blRes = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
body: buf,
|
||||
credentials: 'include'
|
||||
});
|
||||
} else {
|
||||
blRes = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
}
|
||||
|
||||
if (!blRes.ok) throw new Error(await blRes.text());
|
||||
const data = await blRes.json();
|
||||
created = data;
|
||||
|
||||
const savedClientId = data?.clientId ?? data?.client_id;
|
||||
const savedClientSecret = data?.clientSecret ?? data?.client_secret;
|
||||
if (!savedClientId || !savedClientSecret) throw new Error('BeatLeader response missing credentials');
|
||||
|
||||
// Persist credentials locally
|
||||
const saveRes = await fetch('/api/beatleader/oauth/creds', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: savedClientId, client_secret: savedClientSecret })
|
||||
});
|
||||
if (!saveRes.ok) throw new Error(await saveRes.text());
|
||||
|
||||
existing = { client_id: savedClientId, client_secret: savedClientSecret };
|
||||
message = 'Registered successfully. Credentials saved.';
|
||||
} catch (err) {
|
||||
const base = err instanceof Error ? err.message : 'Registration failed';
|
||||
message = base || 'Registration failed. Ensure you are logged into BeatLeader in this browser.';
|
||||
}
|
||||
}
|
||||
|
||||
function generateClientId(): string {
|
||||
return 'bl_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function generateDefaultClientId(): string {
|
||||
try {
|
||||
// Prefer uuid for readability; strip dashes
|
||||
const uuid = (crypto?.randomUUID?.() ?? '') as string;
|
||||
if (uuid) return 'bl_' + uuid.replace(/-/g, '');
|
||||
} catch {}
|
||||
return generateClientId();
|
||||
}
|
||||
|
||||
async function copy(text: string, kind: string) {
|
||||
try {
|
||||
copying = kind;
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setTimeout(() => (copying = null), 800);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader OAuth Setup</h1>
|
||||
<p class="mt-2 text-muted">Register an OAuth application and store credentials for this site.</p>
|
||||
|
||||
<div class="mt-2 text-xs text-muted flex flex-wrap items-center gap-2">
|
||||
{#if blCredsStatus === 'unknown'}
|
||||
<span class="rounded bg-white/10 px-2 py-1">Checking BeatLeader access…</span>
|
||||
{:else if blCredsStatus === 'configured'}
|
||||
<span class="rounded bg-emerald-500/20 text-emerald-300 px-2 py-1">BeatLeader access: Connected</span>
|
||||
{#if blManageUrl}
|
||||
<a class="underline hover:text-white" href={blManageUrl}>Manage</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="rounded bg-rose-500/20 text-rose-300 px-2 py-1">BeatLeader access: Not connected</span>
|
||||
{#if blManageUrl}
|
||||
<a class="underline hover:text-white" href={blManageUrl}>Configure</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if returnTo}
|
||||
<div class="mt-4">
|
||||
<a class="rounded-md border border-white/10 px-3 py-1.5 text-sm hover:border-white/20" href={returnTo}>Return to previous page</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 grid gap-3 max-w-xl text-sm">
|
||||
<div class="text-muted">Manual setup (recommended):</div>
|
||||
<ol class="list-decimal pl-5 grid gap-2">
|
||||
<li>
|
||||
Open the BeatLeader developer portal:
|
||||
<a class="underline" href="https://beatleader.com/developer" target="_blank" rel="noreferrer">beatleader.com/developer</a>
|
||||
</li>
|
||||
<li>
|
||||
Create a new application using the following values:
|
||||
<div class="mt-2 grid gap-2">
|
||||
<div>
|
||||
<div class="text-muted">Suggested Client ID</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code>{recommendedClientId}</code>
|
||||
<button type="button" class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20" on:click={() => copy(recommendedClientId, 'clientId')}>
|
||||
{copying === 'clientId' ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted">Redirect URL</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code>{origin}/auth/beatleader/callback</code>
|
||||
<button type="button" class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20" on:click={() => copy(`${origin}/auth/beatleader/callback`, 'redirect')}>
|
||||
{copying === 'redirect' ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted">Scopes</div>
|
||||
<code>scp:profile{scopes.includes('scp:offline_access') ? ',scp:offline_access' : ''}</code>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
After creation, copy the shown client secret and paste both values below to save locally.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{#if existing}
|
||||
<div class="mt-4 text-sm">
|
||||
<div class="text-muted">Existing credentials found.</div>
|
||||
<div class="mt-1">Client ID: <code>{existing.client_id}</code></div>
|
||||
<div>Client Secret: <code>{existing.client_secret}</code></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm text-muted">Enter credentials manually</summary>
|
||||
<form class="mt-3 grid gap-3 max-w-xl" on:submit|preventDefault={saveManual}>
|
||||
<div>
|
||||
<label class="block text-sm text-muted" for="mClientId">Client ID</label>
|
||||
<input id="mClientId" class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={manualClientId} required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-muted" for="mClientSecret">Client Secret</label>
|
||||
<input id="mClientSecret" class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={manualClientSecret} required />
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-neon">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
{#if message}
|
||||
<div class="mt-4 text-sm">{message}</div>
|
||||
{/if}
|
||||
|
||||
{#if created}
|
||||
<div class="mt-6 text-sm">
|
||||
<div><strong>Client ID:</strong> <code>{created.clientId}</code></div>
|
||||
<div><strong>Client Secret:</strong> <code>{created.clientSecret}</code></div>
|
||||
<div class="mt-2">
|
||||
<a class="rounded-md border border-white/10 px-3 py-1.5 text-sm hover:border-white/20" href="/auth/beatleader/login">Proceed to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
453
src/routes/tools/beatleader-playlist-gap/+page.svelte
Normal file
453
src/routes/tools/beatleader-playlist-gap/+page.svelte
Normal file
@ -0,0 +1,453 @@
|
||||
<script lang="ts">
|
||||
import SongPlayer from '$lib/components/SongPlayer.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Difficulty = {
|
||||
name: string;
|
||||
characteristic: string;
|
||||
};
|
||||
|
||||
type PlaylistSong = {
|
||||
hash: string;
|
||||
difficulties?: Difficulty[];
|
||||
key?: string;
|
||||
levelId?: string;
|
||||
songName?: string;
|
||||
};
|
||||
|
||||
type Playlist = {
|
||||
playlistTitle?: string;
|
||||
songs?: PlaylistSong[];
|
||||
playlistAuthor?: string;
|
||||
image?: string | null;
|
||||
coverImage?: string | null;
|
||||
description?: string;
|
||||
allowDuplicates?: boolean;
|
||||
customData?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type BeatLeaderScore = {
|
||||
leaderboard?: {
|
||||
id?: string | number | null;
|
||||
leaderboardId?: string | number | null;
|
||||
song?: { hash?: string | null };
|
||||
};
|
||||
};
|
||||
|
||||
type BeatLeaderScoresResponse = {
|
||||
data?: BeatLeaderScore[];
|
||||
};
|
||||
|
||||
type MapMeta = {
|
||||
songName?: string;
|
||||
key?: string;
|
||||
coverURL?: string;
|
||||
mapper?: string;
|
||||
};
|
||||
|
||||
let playerId = '';
|
||||
let selectedFileName: string | null = null;
|
||||
let parsedTitle: string | null = null;
|
||||
let playlistSongs: PlaylistSong[] = [];
|
||||
|
||||
let loading = false;
|
||||
let errorMsg: string | null = null;
|
||||
|
||||
let results: PlaylistSong[] = [];
|
||||
let metaByHash: Record<string, MapMeta> = {};
|
||||
let loadingMeta = false;
|
||||
let blUrlByHash: Record<string, string> = {};
|
||||
|
||||
// Persist playerId across refreshes (client-side only)
|
||||
let hasMounted = false;
|
||||
const PLAYER_ID_KEY = 'ps_bl_gap_playerId';
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type PersistedPlayerId = {
|
||||
value: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
function loadPlayerIdFromStorage(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const raw = localStorage.getItem(PLAYER_ID_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedPlayerId> | string;
|
||||
if (typeof parsed === 'string') {
|
||||
playerId = parsed;
|
||||
return;
|
||||
}
|
||||
const expiresAt = typeof parsed?.expiresAt === 'number' ? parsed.expiresAt : 0;
|
||||
if (expiresAt && Date.now() > expiresAt) {
|
||||
localStorage.removeItem(PLAYER_ID_KEY);
|
||||
return;
|
||||
}
|
||||
const value = typeof parsed?.value === 'string' ? parsed.value : '';
|
||||
if (value) playerId = value;
|
||||
} catch {
|
||||
// Backward-compat for plain string values
|
||||
playerId = raw;
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
try {
|
||||
loadPlayerIdFromStorage();
|
||||
} finally {
|
||||
hasMounted = true;
|
||||
}
|
||||
});
|
||||
$: if (hasMounted && typeof localStorage !== 'undefined') {
|
||||
if (playerId && playerId.trim().length > 0) {
|
||||
const record: PersistedPlayerId = { value: playerId, expiresAt: Date.now() + WEEK_MS };
|
||||
localStorage.setItem(PLAYER_ID_KEY, JSON.stringify(record));
|
||||
} else {
|
||||
localStorage.removeItem(PLAYER_ID_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
selectedFileName = file.name;
|
||||
errorMsg = null;
|
||||
results = [];
|
||||
playlistSongs = [];
|
||||
parsedTitle = null;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text) as Playlist;
|
||||
const songs = Array.isArray(data?.songs) ? data.songs : [];
|
||||
const cleaned = songs
|
||||
.filter((s) => s && typeof s.hash === 'string' && s.hash.trim().length > 0)
|
||||
.map((s) => ({
|
||||
hash: s.hash.trim(),
|
||||
difficulties: Array.isArray(s.difficulties) ? s.difficulties : [],
|
||||
key: s.key,
|
||||
levelId: s.levelId,
|
||||
songName: s.songName
|
||||
}));
|
||||
if (cleaned.length === 0) {
|
||||
throw new Error('No valid songs found in playlist.');
|
||||
}
|
||||
playlistSongs = cleaned;
|
||||
parsedTitle = data?.playlistTitle ?? null;
|
||||
} catch (err) {
|
||||
errorMsg = err instanceof Error ? err.message : 'Failed to parse playlist file.';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllScoresAnyTime(player: string, maxPages = 200): Promise<BeatLeaderScore[]> {
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
const all: BeatLeaderScore[] = [];
|
||||
while (page <= maxPages) {
|
||||
const url = `/api/beatleader/player/${encodeURIComponent(player)}?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 ${player}: ${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;
|
||||
}
|
||||
|
||||
async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data: any = await res.json();
|
||||
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
|
||||
return {
|
||||
songName: data?.metadata?.songName ?? data?.name ?? undefined,
|
||||
key: data?.id ?? undefined,
|
||||
coverURL: cover,
|
||||
mapper: data?.uploader?.name ?? undefined
|
||||
};
|
||||
} catch {
|
||||
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetaForResults(items: PlaylistSong[]): Promise<void> {
|
||||
const needed = Array.from(new Set(items.map((i) => i.hash.toLowerCase()))).filter((h) => !metaByHash[h]);
|
||||
if (needed.length === 0) return;
|
||||
loadingMeta = true;
|
||||
for (const h of needed) {
|
||||
const meta = await fetchBeatSaverMeta(h);
|
||||
if (meta) metaByHash = { ...metaByHash, [h]: meta };
|
||||
}
|
||||
loadingMeta = false;
|
||||
}
|
||||
|
||||
type BLDifficulty = {
|
||||
modeName?: string;
|
||||
difficultyName?: string;
|
||||
value?: number;
|
||||
ModeName?: string;
|
||||
DifficultyName?: string;
|
||||
Value?: number;
|
||||
};
|
||||
type BLLeaderboardInfo = {
|
||||
id?: string | number | null;
|
||||
Id?: string | number | null;
|
||||
leaderboardId?: string | number | null;
|
||||
LeaderboardId?: string | number | null;
|
||||
difficulty?: BLDifficulty;
|
||||
Difficulty?: BLDifficulty;
|
||||
};
|
||||
type BLLeaderboardsByHashResponse = {
|
||||
leaderboards?: BLLeaderboardInfo[];
|
||||
Leaderboards?: BLLeaderboardInfo[];
|
||||
};
|
||||
|
||||
function normalizeName(name?: string | null): string {
|
||||
return (name ?? '').toString().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
function difficultyRankIndex(diffName?: string | null): number {
|
||||
const order = ['easy', 'normal', 'hard', 'expert', 'expert+', 'expertplus'];
|
||||
const n = normalizeName(diffName).replace('expertplus', 'expert+');
|
||||
const idx = order.indexOf(n);
|
||||
return idx === -1 ? -1 : idx;
|
||||
}
|
||||
function getDifficulty(obj?: BLDifficulty | null): BLDifficulty {
|
||||
const o = obj ?? {} as BLDifficulty;
|
||||
return {
|
||||
modeName: (o.modeName ?? (o as any).ModeName) as string | undefined,
|
||||
difficultyName: (o.difficultyName ?? (o as any).DifficultyName) as string | undefined,
|
||||
value: (o.value ?? (o as any).Value) as number | undefined
|
||||
};
|
||||
}
|
||||
function pickPreferredLeaderboard(lbs: BLLeaderboardInfo[] | null | undefined): BLLeaderboardInfo | null {
|
||||
if (!Array.isArray(lbs) || lbs.length === 0) return null;
|
||||
const getDiff = (lb: BLLeaderboardInfo) => getDifficulty(lb.difficulty ?? (lb as any).Difficulty);
|
||||
const isStandard = (lb: BLLeaderboardInfo) => normalizeName(getDiff(lb)?.modeName) === 'standard';
|
||||
const inStandard = lbs.filter(isStandard);
|
||||
const pool = inStandard.length > 0 ? inStandard : lbs;
|
||||
const expertPlus = pool.find((lb) => {
|
||||
const n = normalizeName(getDiff(lb)?.difficultyName);
|
||||
return n === 'expertplus' || n === 'expert+';
|
||||
});
|
||||
if (expertPlus) return expertPlus;
|
||||
// Fallback to highest difficulty by known order, then by numeric value if available
|
||||
const byKnownOrder = [...pool].sort((a, b) => difficultyRankIndex(getDiff(a)?.difficultyName) - difficultyRankIndex(getDiff(b)?.difficultyName));
|
||||
const bestByOrder = byKnownOrder[byKnownOrder.length - 1];
|
||||
if (bestByOrder && difficultyRankIndex(getDiff(bestByOrder)?.difficultyName) >= 0) return bestByOrder;
|
||||
const byValue = [...pool].sort((a, b) => (getDiff(a)?.value ?? 0) - (getDiff(b)?.value ?? 0));
|
||||
return byValue[byValue.length - 1] ?? pool[0] ?? null;
|
||||
}
|
||||
async function fetchBLLeaderboardsByHash(hash: string): Promise<BLLeaderboardInfo[] | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/beatleader?path=${encodeURIComponent('/leaderboards/hash/' + hash)}`);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as BLLeaderboardsByHashResponse | unknown;
|
||||
const leaderboards = (data as any)?.leaderboards ?? (data as any)?.Leaderboards;
|
||||
return Array.isArray(leaderboards) ? (leaderboards as BLLeaderboardInfo[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function extractLeaderboardId(lb: BLLeaderboardInfo | null): string | null {
|
||||
if (!lb) return null;
|
||||
const anyLb: any = lb as any;
|
||||
const id = anyLb.id ?? anyLb.leaderboardId ?? anyLb.Id ?? anyLb.LeaderboardId;
|
||||
return id != null ? String(id) : null;
|
||||
}
|
||||
function buildBLUrlFromLeaderboard(lb: BLLeaderboardInfo | null, hash: string): string {
|
||||
const id = extractLeaderboardId(lb);
|
||||
if (id && id.length > 0) {
|
||||
return `https://beatleader.com/leaderboard/global/${id}`;
|
||||
}
|
||||
// Fallback: search by hash on the site
|
||||
return `https://beatleader.com/leaderboards?search=${encodeURIComponent(hash)}`;
|
||||
}
|
||||
|
||||
async function openBeatLeader(hash: string): Promise<void> {
|
||||
const key = (hash ?? '').toLowerCase();
|
||||
let url = blUrlByHash[key];
|
||||
if (!url) {
|
||||
const lbs = await fetchBLLeaderboardsByHash(key);
|
||||
const picked = pickPreferredLeaderboard(lbs ?? undefined);
|
||||
url = buildBLUrlFromLeaderboard(picked, hash);
|
||||
blUrlByHash = { ...blUrlByHash, [key]: url };
|
||||
}
|
||||
if (typeof window !== 'undefined' && url) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPlaylist(): void {
|
||||
const title = `playlist_gap_${playerId || 'player'}`;
|
||||
const payload = {
|
||||
playlistTitle: title,
|
||||
playlistAuthor: 'SaberList Tool',
|
||||
songs: results.map((s) => ({ hash: s.hash, difficulties: s.difficulties ?? [] })),
|
||||
description: `Subset of ${(parsedTitle ?? 'playlist')} that ${playerId} has not played. Generated ${new Date().toISOString()}`,
|
||||
allowDuplicates: false,
|
||||
customData: {}
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title}.bplist`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function onAnalyze(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
errorMsg = null;
|
||||
results = [];
|
||||
metaByHash = {};
|
||||
if (!playerId.trim()) {
|
||||
errorMsg = 'Please enter a BeatLeader player ID or SteamID64.';
|
||||
return;
|
||||
}
|
||||
if (playlistSongs.length === 0) {
|
||||
errorMsg = 'Please select a valid .bplist file first.';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const scores = await fetchAllScoresAnyTime(playerId.trim(), 150);
|
||||
const playedHashes = new Set<string>();
|
||||
for (const s of scores) {
|
||||
const raw = s.leaderboard?.song?.hash ?? undefined;
|
||||
if (!raw) continue;
|
||||
playedHashes.add(String(raw).toLowerCase());
|
||||
}
|
||||
const unplayed: PlaylistSong[] = [];
|
||||
for (const song of playlistSongs) {
|
||||
const h = song.hash?.toLowerCase?.() ?? '';
|
||||
if (!h) continue;
|
||||
if (!playedHashes.has(h)) {
|
||||
unplayed.push(song);
|
||||
}
|
||||
}
|
||||
results = unplayed;
|
||||
loadMetaForResults(unplayed);
|
||||
} catch (err) {
|
||||
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Playlist Gap</h1>
|
||||
<p class="mt-2 text-muted">Upload a .bplist and enter a player ID to find songs they have not played.</p>
|
||||
|
||||
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onAnalyze}>
|
||||
<div class="sm:col-span-2 lg:col-span-2">
|
||||
<label class="block text-sm text-muted">Playlist file (.bplist)
|
||||
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="file" accept=".bplist,application/json" on:change={onFileChange} />
|
||||
</label>
|
||||
{#if selectedFileName}
|
||||
<div class="mt-1 text-xs text-muted">{selectedFileName}{#if parsedTitle} · title: {parsedTitle}{/if} · {playlistSongs.length} songs</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Player ID
|
||||
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerId} placeholder="7656119... or BL ID" required />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-neon" disabled={loading}>
|
||||
{#if loading}
|
||||
Loading...
|
||||
{:else}
|
||||
Analyze
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="mt-4 text-danger">{errorMsg}</div>
|
||||
{/if}
|
||||
|
||||
{#if results.length > 0}
|
||||
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3 text-sm text-muted">
|
||||
<span>{results.length} songs not played</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loadingMeta}
|
||||
<div class="mt-2 text-xs text-muted">Loading covers…</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each results as item}
|
||||
<article class="card-surface overflow-hidden">
|
||||
<div class="aspect-square bg-black/30">
|
||||
{#if metaByHash[item.hash?.toLowerCase?.() || '']?.coverURL}
|
||||
<img
|
||||
src={metaByHash[item.hash.toLowerCase()].coverURL}
|
||||
alt={metaByHash[item.hash.toLowerCase()]?.songName ?? item.hash}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="font-semibold truncate" title={metaByHash[item.hash.toLowerCase()]?.songName ?? item.hash}>
|
||||
{metaByHash[item.hash.toLowerCase()]?.songName ?? item.hash}
|
||||
</div>
|
||||
{#if metaByHash[item.hash.toLowerCase()]?.mapper}
|
||||
<div class="mt-0.5 text-xs text-muted truncate">{metaByHash[item.hash.toLowerCase()]?.mapper}</div>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
<SongPlayer hash={item.hash} preferBeatLeader={true} />
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
href={blUrlByHash[item.hash.toLowerCase()] ?? undefined}
|
||||
on:click|preventDefault={() => openBeatLeader(item.hash)}
|
||||
on:auxclick|preventDefault={() => openBeatLeader(item.hash)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Open in BeatLeader"
|
||||
>BL</a>
|
||||
<a
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
href={metaByHash[item.hash.toLowerCase()]?.key ? `https://beatsaver.com/maps/${metaByHash[item.hash.toLowerCase()]?.key}` : `https://beatsaver.com/search/hash/${item.hash}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Open in BeatSaver"
|
||||
>BSR</a>
|
||||
<button
|
||||
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.toLowerCase()]?.key; if (key) navigator.clipboard.writeText(`!bsr ${key}`); }}
|
||||
disabled={!metaByHash[item.hash.toLowerCase()]?.key}
|
||||
title="Copy !bsr"
|
||||
>Copy !bsr</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.text-danger { color: #dc2626; }
|
||||
.btn-neon { cursor: pointer; }
|
||||
.card-surface { border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); }
|
||||
.text-muted { color: rgba(255,255,255,0.7); }
|
||||
</style>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user