diff --git a/src/lib/server/osu/api.ts b/src/lib/server/osu/api.ts new file mode 100644 index 0000000..8104fb2 --- /dev/null +++ b/src/lib/server/osu/api.ts @@ -0,0 +1,119 @@ +import { env } from '$env/dynamic/private'; +import { db } from '$lib/server/db'; +import { and, eq } from 'drizzle-orm'; +import { account } from '$lib/server/db/auth.schema'; + +let clientToken: string | null = null; +let clientTokenExpiresAt = 0; + +// Client credentials token (no user needed, for public endpoints) +async function getClientToken(): Promise { + if (clientToken && Date.now() < clientTokenExpiresAt - 60_000) { + return clientToken; + } + + const res = await fetch('https://osu.ppy.sh/oauth/token', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: env.OSU_CLIENT_ID!, + client_secret: env.OSU_CLIENT_SECRET!, + grant_type: 'client_credentials', + scope: 'identify public' + }) + }); + + if (!res.ok) { + throw new Error(`Failed to get osu! client token: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + clientToken = data.access_token; + clientTokenExpiresAt = Date.now() + data.expires_in * 1000; + return clientToken!; +} + +// Get a user's access token (for user-scoped endpoints) +async function getUserToken(userId: string): Promise { + const acc = await db.query.account.findFirst({ + where: and(eq(account.userId, userId), eq(account.providerId, 'osu')) + }); + + if (!acc?.accessToken) return null; + + // Check if expired and refresh if needed + if (acc.accessTokenExpiresAt && acc.accessTokenExpiresAt < new Date()) { + if (!acc.refreshToken) return null; + + const res = await fetch('https://osu.ppy.sh/oauth/token', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: env.OSU_CLIENT_ID!, + client_secret: env.OSU_CLIENT_SECRET!, + grant_type: 'refresh_token', + refresh_token: acc.refreshToken, + scope: 'identify public' + }) + }); + + if (!res.ok) return null; + + const data = await res.json(); + await db + .update(account) + .set({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000) + }) + .where(eq(account.id, acc.id)); + + return data.access_token; + } + + return acc.accessToken; +} + +async function osuFetch(path: string, token?: string) { + const t = token ?? (await getClientToken()); + const res = await fetch(`https://osu.ppy.sh/api/v2${path}`, { + headers: { Authorization: `Bearer ${t}` } + }); + + if (!res.ok) { + throw new Error(`osu! API ${path}: ${res.status} ${res.statusText}`); + } + + return res.json(); +} + +// ── Public API ────────────────────────────────────────────────────────── + +export async function getBeatmap(beatmapId: string | number) { + return osuFetch(`/beatmaps/${beatmapId}`); +} + +export async function getBeatmapset(beatmapsetId: string | number) { + return osuFetch(`/beatmapsets/${beatmapsetId}`); +} + +export async function getUser(userId: string | number) { + return osuFetch(`/users/${userId}`); +} + +export async function getUserScores( + userId: string | number, + type: 'best' | 'recent' | 'firsts' = 'best', + limit = 10 +) { + return osuFetch(`/users/${userId}/scores/${type}?limit=${limit}`); +} + +export { getUserToken, getClientToken }; diff --git a/src/routes/api/osu/beatmap/[id]/+server.ts b/src/routes/api/osu/beatmap/[id]/+server.ts new file mode 100644 index 0000000..71f5215 --- /dev/null +++ b/src/routes/api/osu/beatmap/[id]/+server.ts @@ -0,0 +1,14 @@ +import { json, error } from '@sveltejs/kit'; +import { getBeatmap } from '$lib/server/osu/api'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + try { + const beatmap = await getBeatmap(params.id); + return json(beatmap); + } catch { + error(404, 'Beatmap not found'); + } +};