settings + reinvite players to lobby
This commit is contained in:
@@ -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
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
42
src/lib/server/osu/cache.ts
Normal file
42
src/lib/server/osu/cache.ts
Normal 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();
|
||||
}
|
||||
56
src/lib/server/permissions.ts
Normal file
56
src/lib/server/permissions.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user