settings + reinvite players to lobby

This commit is contained in:
2026-02-28 05:49:56 +01:00
parent f4b94597c7
commit 90396b5338
32 changed files with 1000 additions and 268 deletions

View File

@@ -1,3 +1,4 @@
// src/lib/server/auth.ts
import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
@@ -48,7 +49,8 @@ export const auth = betterAuth({
scopes: ['identify', 'public'],
pkce: false,
authentication: 'post',
redirectURI: 'http://localhost:5173/api/auth/oauth2/callback/osu',
// ── FIX: Use ORIGIN env var instead of hardcoded localhost ──
redirectURI: `${env.ORIGIN}/api/auth/oauth2/callback/osu`,
getToken: async ({ code, redirectURI }) => {
const res = await fetch('https://osu.ppy.sh/oauth/token', {
method: 'POST',
@@ -87,6 +89,6 @@ export const auth = betterAuth({
}
]
}),
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
sveltekitCookies(getRequestEvent) // must be last
]
});

View File

@@ -1,93 +1,96 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
// src/lib/server/db/auth.schema.ts
import { relations } from 'drizzle-orm';
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
// ── Staff role: 'player' | 'referee' | 'admin' ──
role: text('role').default('player').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date())
.notNull()
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
},
(table) => [index('session_userId_idx').on(table.userId)]
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => new Date())
.notNull()
},
(table) => [index('account_userId_idx').on(table.userId)]
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date())
.notNull()
},
(table) => [index('verification_identifier_idx').on(table.identifier)]
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
sessions: many(session),
accounts: many(account)
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
user: one(user, {
fields: [session.userId],
references: [user.id]
})
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
user: one(user, {
fields: [account.userId],
references: [user.id]
})
}));

View File

@@ -1,3 +1,8 @@
// src/lib/server/db/schema.ts
//
// CHANGE: Add `role` column to user table in auth.schema.ts (see that file).
// This file adds the permission helper utilities.
import {
pgTable,
integer,

View File

@@ -1,12 +1,13 @@
// src/lib/server/osu/api.ts
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';
import { getCached, setCache } from './cache';
let clientToken: string | null = null;
let clientTokenExpiresAt = 0;
// Client credentials token (no user needed, for public endpoints)
async function getClientToken(): Promise<string> {
if (clientToken && Date.now() < clientTokenExpiresAt - 60_000) {
return clientToken;
@@ -36,7 +37,6 @@ async function getClientToken(): Promise<string> {
return clientToken!;
}
// Get a user's access token (for user-scoped endpoints)
async function getUserToken(userId: string): Promise<string | null> {
const acc = await db.query.account.findFirst({
where: and(eq(account.userId, userId), eq(account.providerId, 'osu'))
@@ -44,7 +44,6 @@ async function getUserToken(userId: string): Promise<string | null> {
if (!acc?.accessToken) return null;
// Check if expired and refresh if needed
if (acc.accessTokenExpiresAt && acc.accessTokenExpiresAt < new Date()) {
if (!acc.refreshToken) return null;
@@ -94,18 +93,36 @@ async function osuFetch(path: string, token?: string) {
return res.json();
}
// ── Public API ──────────────────────────────────────────────────────────
// ── Public API (with caching) ───────────────────────────────────────────
export async function getBeatmap(beatmapId: string | number) {
return osuFetch(`/beatmaps/${beatmapId}`);
const cacheKey = `beatmap:${beatmapId}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const data = await osuFetch(`/beatmaps/${beatmapId}`);
setCache(cacheKey, data);
return data;
}
export async function getBeatmapset(beatmapsetId: string | number) {
return osuFetch(`/beatmapsets/${beatmapsetId}`);
const cacheKey = `beatmapset:${beatmapsetId}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const data = await osuFetch(`/beatmapsets/${beatmapsetId}`);
setCache(cacheKey, data);
return data;
}
export async function getUser(userId: string | number) {
return osuFetch(`/users/${userId}`);
const cacheKey = `osu_user:${userId}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const data = await osuFetch(`/users/${userId}`);
setCache(cacheKey, data, 5 * 60 * 1000); // 5 min for user data
return data;
}
export async function getUserScores(

View File

@@ -0,0 +1,42 @@
// src/lib/server/osu/cache.ts
//
// Simple in-memory cache for osu! beatmap metadata.
// TTL: 10 minutes. Max entries: 500.
// Avoids hammering the osu! API on every page load.
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
const MAX_ENTRIES = 500;
const cache = new Map<string, CacheEntry<any>>();
export function getCached<T>(key: string): T | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data as T;
}
export function setCache<T>(key: string, data: T, ttl = CACHE_TTL): void {
// Evict oldest entries if at capacity
if (cache.size >= MAX_ENTRIES) {
const firstKey = cache.keys().next().value;
if (firstKey) cache.delete(firstKey);
}
cache.set(key, { data, expiresAt: Date.now() + ttl });
}
export function invalidateCache(key: string): void {
cache.delete(key);
}
export function clearCache(): void {
cache.clear();
}

View File

@@ -0,0 +1,56 @@
// src/lib/server/permissions.ts
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/auth.schema';
import { eq } from 'drizzle-orm';
export type UserRole = 'player' | 'referee' | 'admin';
const ROLE_HIERARCHY: Record<UserRole, number> = {
player: 0,
referee: 1,
admin: 2
};
/**
* Check if a role meets the minimum required level.
*/
export function hasRole(userRole: string | undefined, minRole: UserRole): boolean {
const level = ROLE_HIERARCHY[userRole as UserRole] ?? 0;
const required = ROLE_HIERARCHY[minRole];
return level >= required;
}
/**
* Throw 403 if user doesn't have the required role.
* Use in +page.server.ts load/actions.
*/
export function requireRole(
locals: App.Locals,
minRole: UserRole,
message = 'Insufficient permissions'
) {
if (!locals.user) {
error(401, 'Not logged in');
}
if (!hasRole(locals.user.role, minRole)) {
error(403, message);
}
}
/**
* Promote/demote a user's global role. Only admins can do this.
*/
export async function setUserRole(userId: string, role: UserRole) {
await db.update(user).set({ role }).where(eq(user.id, userId));
}
/**
* Get a user's role from the database (for fresh reads).
*/
export async function getUserRole(userId: string): Promise<UserRole> {
const u = await db.query.user.findFirst({
where: eq(user.id, userId)
});
return (u?.role as UserRole) ?? 'player';
}