feat: add admin panel with Discord OAuth and dashboard

Adds a React admin panel (panel/) with Discord OAuth2 login,
live dashboard via WebSocket, and settings/management pages.
Includes Docker build support, Vite proxy config for dev,
game_settings migration, and open-redirect protection on auth callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-02-13 20:27:14 +01:00
parent 121c242168
commit 2381f073ba
30 changed files with 3626 additions and 11 deletions

View File

@@ -0,0 +1,233 @@
/**
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
* Handles login flow, callback, logout, and session management.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger";
// In-memory session store: token → { discordId, username, avatar, expiresAt }
export interface Session {
discordId: string;
username: string;
avatar: string | null;
expiresAt: number;
}
const sessions = new Map<string, Session>();
const redirects = new Map<string, string>(); // redirect token -> return_to URL
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
function getEnv(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing env: ${key}`);
return val;
}
function getAdminIds(): string[] {
const raw = process.env.ADMIN_USER_IDS ?? "";
return raw.split(",").map(s => s.trim()).filter(Boolean);
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
function getBaseUrl(): string {
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
}
function parseCookies(header: string | null): Record<string, string> {
if (!header) return {};
const cookies: Record<string, string> = {};
for (const pair of header.split(";")) {
const [key, ...rest] = pair.trim().split("=");
if (key) cookies[key] = rest.join("=");
}
return cookies;
}
/** Get session from request cookie */
export function getSession(req: Request): Session | null {
const cookies = parseCookies(req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(token);
return null;
}
return session;
}
/** Check if request is authenticated as admin */
export function isAuthenticated(req: Request): boolean {
return getSession(req) !== null;
}
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
// GET /auth/discord — redirect to Discord OAuth
if (pathname === "/auth/discord" && method === "GET") {
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const baseUrl = getBaseUrl();
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
const scope = "identify+email";
// Store return_to URL if provided
const returnTo = ctx.url.searchParams.get("return_to") || "/";
const redirectToken = generateToken();
redirects.set(redirectToken, returnTo);
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
// Set a temporary cookie with the redirect token
return new Response(null, {
status: 302,
headers: {
Location: url,
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
},
});
} catch (e) {
logger.error("auth", "Failed to initiate OAuth", e);
return errorResponse("OAuth not configured", 500);
}
}
// GET /auth/callback — handle Discord OAuth callback
if (pathname === "/auth/callback" && method === "GET") {
const code = ctx.url.searchParams.get("code");
if (!code) return errorResponse("Missing code parameter", 400);
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
const baseUrl = getBaseUrl();
const redirectUri = `${baseUrl}/auth/callback`;
// Exchange code for token
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) {
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
return errorResponse("OAuth token exchange failed", 401);
}
const tokenData = await tokenRes.json() as { access_token: string };
// Fetch user info
const userRes = await fetch("https://discord.com/api/users/@me", {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) {
return errorResponse("Failed to fetch Discord user", 401);
}
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
// Check allowlist
const adminIds = getAdminIds();
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
return new Response(
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
{ status: 403, headers: { "Content-Type": "text/html" } }
);
}
// Create session
const token = generateToken();
sessions.set(token, {
discordId: user.id,
username: user.username,
avatar: user.avatar,
expiresAt: Date.now() + SESSION_MAX_AGE,
});
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
// Get return_to URL from redirect token cookie
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const redirectToken = cookies["aurora_redirect"];
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
if (redirectToken) redirects.delete(redirectToken);
// Only allow redirects to localhost or relative paths (prevent open redirect)
try {
const parsed = new URL(returnTo, baseUrl);
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
returnTo = "/";
}
} catch {
returnTo = "/";
}
// Redirect to panel with session cookie
return new Response(null, {
status: 302,
headers: {
Location: returnTo,
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
},
});
} catch (e) {
logger.error("auth", "OAuth callback error", e);
return errorResponse("Authentication failed", 500);
}
}
// POST /auth/logout — clear session
if (pathname === "/auth/logout" && method === "POST") {
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (token) sessions.delete(token);
return new Response(null, {
status: 200,
headers: {
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
"Content-Type": "application/json",
},
});
}
// GET /auth/me — return current session info
if (pathname === "/auth/me" && method === "GET") {
const session = getSession(ctx.req);
if (!session) return jsonResponse({ authenticated: false }, 401);
return jsonResponse({
authenticated: true,
user: {
discordId: session.discordId,
username: session.username,
avatar: session.avatar,
},
});
}
return null;
}
export const authRoutes: RouteModule = {
name: "auth",
handler,
};

View File

@@ -4,6 +4,7 @@
*/
import type { RouteContext, RouteModule } from "./types";
import { authRoutes, isAuthenticated } from "./auth.routes";
import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes";
@@ -17,13 +18,16 @@ import { moderationRoutes } from "./moderation.routes";
import { transactionsRoutes } from "./transactions.routes";
import { lootdropsRoutes } from "./lootdrops.routes";
import { assetsRoutes } from "./assets.routes";
import { errorResponse } from "./utils";
/**
* All registered route modules in order of precedence.
* Routes are checked in order; the first matching route wins.
*/
const routeModules: RouteModule[] = [
/** Routes that do NOT require authentication */
const publicRoutes: RouteModule[] = [
authRoutes,
healthRoutes,
];
/** Routes that require an authenticated admin session */
const protectedRoutes: RouteModule[] = [
statsRoutes,
actionsRoutes,
questsRoutes,
@@ -58,14 +62,25 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
pathname: url.pathname,
};
// Try each route module in order
for (const module of routeModules) {
// Try public routes first (auth, health)
for (const module of publicRoutes) {
const response = await module.handler(ctx);
if (response !== null) {
return response;
if (response !== null) return response;
}
// For API routes, enforce authentication
if (ctx.pathname.startsWith("/api/")) {
if (!isAuthenticated(req)) {
return errorResponse("Unauthorized", 401);
}
}
// Try protected routes
for (const module of protectedRoutes) {
const response = await module.handler(ctx);
if (response !== null) return response;
}
return null;
}
@@ -74,5 +89,5 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
* Useful for debugging and documentation.
*/
export function getRegisteredRoutes(): string[] {
return routeModules.map(m => m.name);
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
}

View File

@@ -7,10 +7,11 @@
* Each route module handles its own validation, business logic, and responses.
*/
import { serve } from "bun";
import { serve, file } from "bun";
import { logger } from "@shared/lib/logger";
import { handleRequest } from "./routes";
import { getFullDashboardStats } from "./routes/stats.helper";
import { join } from "path";
export interface WebServerConfig {
port?: number;
@@ -38,6 +39,54 @@ export interface WebServerInstance {
* // To stop the server:
* await server.stop();
*/
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* Serve static files from the panel dist directory.
* Falls back to index.html for SPA routing.
*/
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
// Don't serve panel for API/auth/ws/assets routes
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
return null;
}
// Try to serve the exact file
const filePath = join(distDir, pathname);
const bunFile = file(filePath);
if (await bunFile.exists()) {
const ext = pathname.substring(pathname.lastIndexOf("."));
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
return new Response(bunFile, {
headers: {
"Content-Type": contentType,
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
},
});
}
// SPA fallback: serve index.html for all non-file routes
const indexFile = file(join(distDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
});
}
return null;
}
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
const { port = 3000, hostname = "localhost" } = config;
@@ -72,6 +121,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const response = await handleRequest(req, url);
if (response) return response;
// Serve panel static files (production)
const panelDistDir = join(import.meta.dir, "../../panel/dist");
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
if (staticResponse) return staticResponse;
// No matching route found
return new Response("Not Found", { status: 404 });
},