From cace7e178916fd7357526392f0c3649581b814cf Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 24 Feb 2025 08:44:50 -0800 Subject: [PATCH] create rate limit async handler --- src/server/Server.ts | 264 +++++++++++++++++++++--------------- src/server/StructuredLog.ts | 18 +++ 2 files changed, 175 insertions(+), 107 deletions(-) diff --git a/src/server/Server.ts b/src/server/Server.ts index 28bf27557..284c6d5b3 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -1,4 +1,4 @@ -import express, { json } from "express"; +import express, { json, Request, Response, NextFunction } from "express"; import http from "http"; import { WebSocketServer } from "ws"; import path from "path"; @@ -26,7 +26,6 @@ import { sanitizeUsername, validateUsername, } from "../core/validations/username"; -import { Request, Response } from "express"; import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; import dotenv from "dotenv"; import crypto from "crypto"; @@ -83,15 +82,40 @@ try { let lobbiesString = ""; +// Async error wrapper with rate limiting support +const asyncHandler = + (fn: Function, limiter = null) => + async (req: Request, res: Response, next: NextFunction) => { + try { + // Apply rate limiting if a limiter is provided + if (limiter) { + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + try { + await limiter.consume(clientIP); + } catch (error) { + console.warn(`Rate limited for IP ${clientIP}`); + return res.status(429).json({ error: "Too many requests" }); + } + } + + // Execute the route handler + await fn(req, res, next); + } catch (error) { + // Pass any errors to Express error handler + next(error); + } + }; + // Discord OAuth callback endpoint -app.get("/auth/callback", async (req: Request, res: Response) => { - const { code } = req.query; +app.get( + "/auth/callback", + asyncHandler(async (req: Request, res: Response) => { + const { code } = req.query; - if (!code) { - return res.status(400).send("No code provided"); - } + if (!code) { + return res.status(400).send("No code provided"); + } - try { // Exchange code for access token const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { method: "POST", @@ -136,11 +160,8 @@ app.get("/auth/callback", async (req: Request, res: Response) => { maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds }); res.redirect(`/`); - } catch (error) { - console.error("Auth error:", error); - res.status(500).send("Authentication failed"); - } -}); + }), +); app.get("/auth/discord", (req: Request, res: Response) => { console.log("Redirecting to Discord OAuth..."); @@ -155,33 +176,22 @@ app.get("/lobbies", (req: Request, res: Response) => { res.send(lobbiesString); }); -app.post("/private_lobby", async (req, res) => { - let clientIP = ""; - try { - clientIP = req.ip || req.socket.remoteAddress || "unknown"; - await updateRateLimiter.consume(clientIP); - } catch (error) { - console.warn(`create private lobby rate limited for IP ${clientIP}`); - return; - } - const id = gm.createPrivateGame(); - console.log(`ip ${clientIP} creating private lobby with id ${id}`); - res.json({ - id: id, - }); -}); +app.post( + "/private_lobby", + asyncHandler(async (req, res) => { + throw Error("uh oh"); + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + const id = gm.createPrivateGame(); + console.log(`ip ${clientIP} creating private lobby with id ${id}`); + res.json({ + id: id, + }); + }, updateRateLimiter), +); -app.post("/archive_singleplayer_game", async (req, res) => { - let clientIP = ""; - try { - clientIP = req.ip || req.socket.remoteAddress || "unknown"; - await updateRateLimiter.consume(clientIP); - } catch (error) { - console.warn(`archive singleplayer game limited for IP ${clientIP}`); - return; - } - - try { +app.post( + "/archive_singleplayer_game", + asyncHandler(async (req, res) => { const gameRecord: GameRecord = req.body; const clientIP = req.ip || req.socket.remoteAddress || "unknown"; @@ -196,78 +206,84 @@ app.post("/archive_singleplayer_game", async (req, res) => { res.json({ success: true, }); - } catch (error) { - slog({ - logKey: "complete_single_player_game_record", - msg: `Failed to complete game record: ${error}`, - severity: LogSeverity.Error, + }, updateRateLimiter), +); + +app.post( + "/start_private_lobby/:id", + asyncHandler(async (req, res) => { + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + console.log(`starting private lobby with id ${req.params.id}`); + gm.startPrivateGame(req.params.id); + res.status(200).json({ success: true }); + }, updateRateLimiter), +); + +app.put( + "/private_lobby/:id", + asyncHandler(async (req, res) => { + const lobbyID = req.params.id; + gm.updateGameConfig(lobbyID, { + gameMap: req.body.gameMap, + difficulty: req.body.difficulty, + disableBots: req.body.disableBots, + disableNPCs: req.body.disableNPCs, + creativeMode: req.body.creativeMode, }); - res.status(400).json({ error: "Invalid game record format" }); - } -}); + res.status(200).json({ success: true }); + }), +); -app.post("/start_private_lobby/:id", async (req, res) => { - let clientIP = ""; - try { - clientIP = req.ip || req.socket.remoteAddress || "unknown"; - await updateRateLimiter.consume(clientIP); - } catch (error) { - console.warn(`start private lobby rate limited for IP ${clientIP}`); - return; - } - console.log(`starting private lobby with id ${req.params.id}`); - gm.startPrivateGame(req.params.id); -}); +app.get( + "/lobby/:id/exists", + asyncHandler(async (req, res) => { + const lobbyId = req.params.id; + let gameExists = gm.hasActiveGame(lobbyId); + if (!gameExists) { + gameExists = await gameRecordExists(lobbyId); + } + res.json({ + exists: gameExists, + }); + }), +); -app.put("/private_lobby/:id", async (req, res) => { - const lobbyID = req.params.id; - gm.updateGameConfig(lobbyID, { - gameMap: req.body.gameMap, - difficulty: req.body.difficulty, - disableBots: req.body.disableBots, - disableNPCs: req.body.disableNPCs, - creativeMode: req.body.creativeMode, - }); -}); +app.get( + "/lobby/:id", + asyncHandler(async (req, res) => { + const game = gm.game(req.params.id); + if (game == null) { + console.log(`lobby ${req.params.id} not found`); + return res.status(404).json({ error: "Game not found" }); + } + res.json({ + players: game.activeClients.map((c) => ({ + username: c.username, + clientID: c.clientID, + })), + }); + }), +); -app.get("/lobby/:id/exists", async (req, res) => { - const lobbyId = req.params.id; - let gameExists = gm.hasActiveGame(lobbyId); - if (!gameExists) { - gameExists = await gameRecordExists(lobbyId); - } - res.json({ - exists: gameExists, - }); -}); +app.get( + "/private_lobby/:id", + asyncHandler(async (req, res) => { + res.json({ + hi: "5", + }); + }), +); -app.get("/lobby/:id", (req, res) => { - const game = gm.game(req.params.id); - if (game == null) { - console.log(`lobby ${req.params.id} not found`); - return res.status(404).json({ error: "Game not found" }); - } - res.json({ - players: game.activeClients.map((c) => ({ - username: c.username, - clientID: c.clientID, - })), - }); -}); - -app.get("/private_lobby/:id", (req, res) => { - res.json({ - hi: "5", - }); -}); - -app.get("/debug-ip", (req, res) => { - res.send({ - "x-forwarded-for": req.headers["x-forwarded-for"], - "real-ip": req.ip, - "raw-headers": req.rawHeaders, - }); -}); +app.get( + "/debug-ip", + asyncHandler(async (req, res) => { + res.send({ + "x-forwarded-for": req.headers["x-forwarded-for"], + "real-ip": req.ip, + "raw-headers": req.rawHeaders, + }); + }), +); app.get("*", function (req, res) { // SPA routing @@ -289,7 +305,7 @@ wss.on("connection", (ws, req) => { } try { const clientMsg: ClientMessage = ClientMessageSchema.parse( - JSON.parse(message), + JSON.parse(message.toString()), ); if (clientMsg.type == "join") { const forwarded = req.headers["x-forwarded-for"]; @@ -353,6 +369,18 @@ wss.on("connection", (ws, req) => { }); }); +// Global error handler +app.use((err: Error, req: Request, res: Response, next: NextFunction) => { + console.error(`Error in ${req.method} ${req.path}:`, err); + slog({ + logKey: "server_error", + msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`, + severity: LogSeverity.Error, + stack: err.stack, + }); + res.status(500).json({ error: "An unexpected error occurred" }); +}); + function startServer() { setInterval(() => tick(), 1000); setInterval(() => updateLobbies(), 100); @@ -386,6 +414,28 @@ function updateLobbies() { }); } +// Process-level unhandled exception handlers +process.on("uncaughtException", (err) => { + console.error("Uncaught exception:", err); + slog({ + logKey: "uncaught_exception", + msg: `Uncaught exception: ${err.message}`, + severity: LogSeverity.Error, + stack: err.stack, + }); + // Note: We're not exiting the process to maintain uptime + // but be aware the app might be in an inconsistent state +}); + +process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled rejection at:", promise, "reason:", reason); + slog({ + logKey: "unhandled_rejection", + msg: `Unhandled promise rejection: ${reason}`, + severity: LogSeverity.Error, + }); +}); + // Initialize secrets and start server async function initializeSecrets() { try { diff --git a/src/server/StructuredLog.ts b/src/server/StructuredLog.ts index c836878c4..6fcb7e283 100644 --- a/src/server/StructuredLog.ts +++ b/src/server/StructuredLog.ts @@ -8,11 +8,22 @@ export interface slogMsg { gameID?: GameID; clientID?: ClientID; persistentID?: string; + stack?: string; // Added stack property } export function slog(msg: slogMsg): void { msg.severity = msg.severity ?? LogSeverity.Info; + // Format stack trace if available + if (msg.stack) { + // Keep the stack trace in the log data + if (!msg.data) { + msg.data = { stack: msg.stack }; + } else if (typeof msg.data === "object") { + msg.data.stack = msg.stack; + } + } + if (process.env.GAME_ENV == "dev") { // Avoid blowing up the log during development. if (msg.logKey == "client_console_log") { @@ -20,6 +31,10 @@ export function slog(msg: slogMsg): void { } if (msg.severity != LogSeverity.Debug) { console.log(msg.msg); + // Print stack trace in development for errors + if (msg.severity === LogSeverity.Error && msg.stack) { + console.error(msg.stack); + } } } else { try { @@ -28,6 +43,9 @@ export function slog(msg: slogMsg): void { console.error("Failed to stringify log message:", error); // Fallback to basic logging console.log(`${msg.severity}: ${msg.msg}`); + if (msg.severity === LogSeverity.Error && msg.stack) { + console.error(msg.stack); + } } } }