merge branch v0.17.3

This commit is contained in:
Evan
2025-03-06 15:46:57 -08:00
28 changed files with 1398 additions and 456 deletions
+4 -5
View File
@@ -18,12 +18,13 @@ import {
} from "../core/game/GameUpdates";
import { WorkerClient } from "../core/worker/WorkerClient";
import { consolex, initRemoteSender } from "../core/Consolex";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import { getConfig, ServerConfig } from "../core/configuration/Config";
import { GameView, PlayerView } from "../core/game/GameView";
import { GameUpdateViewData } from "../core/game/GameUpdates";
import { UserSettings } from "../core/game/UserSettings";
export interface LobbyConfig {
serverConfig: ServerConfig;
flag: () => string;
playerName: () => string;
clientID: ClientID;
@@ -51,8 +52,6 @@ export function joinLobby(
`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID}`,
);
const serverConfig = getServerConfig();
const userSettings: UserSettings = new UserSettings();
let gameConfig: GameConfig = null;
if (lobbyConfig.gameType == GameType.Singleplayer) {
@@ -72,7 +71,7 @@ export function joinLobby(
lobbyConfig,
gameConfig,
eventBus,
serverConfig,
lobbyConfig.serverConfig,
);
const onconnect = () => {
@@ -106,7 +105,7 @@ export async function createClientGame(
transport: Transport,
userSettings: UserSettings,
): Promise<ClientGameRunner> {
const config = getConfig(gameConfig, userSettings);
const config = await getConfig(gameConfig, userSettings);
const gameMap = await loadTerrainMap(gameConfig.gameMap);
const worker = new WorkerClient(
+4
View File
@@ -249,6 +249,10 @@ export class HelpModal extends LitElement {
<td>C</td>
<td>Center camera on player</td>
</tr>
<tr>
<td>C</td>
<td>Center camera on player</td>
</tr>
<tr>
<td>Q / E</td>
<td>Zoom out/in</td>
+41 -15
View File
@@ -6,9 +6,12 @@ import { consolex } from "../core/Consolex";
import "./components/Difficulties";
import { DifficultyDescription } from "./components/Difficulties";
import "./components/Maps";
import { generateID } from "../core/Util";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import randomMap from "../../resources/images/RandomMap.png";
import { generateID } from "../core/Util";
import {
getConfig,
getServerConfigFromClient,
} from "../core/configuration/Config";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@@ -26,6 +29,8 @@ export class HostLobbyModal extends LitElement {
@state() private useRandomMap: boolean = false;
private playersInterval = null;
// Add a new timer for debouncing bot changes
private botsUpdateTimer: number | null = null;
static styles = css`
.modal-overlay {
@@ -571,11 +576,18 @@ export class HostLobbyModal extends LitElement {
clearInterval(this.playersInterval);
this.playersInterval = null;
}
// Clear any pending bot updates
if (this.botsUpdateTimer !== null) {
clearTimeout(this.botsUpdateTimer);
this.botsUpdateTimer = null;
}
}
private async handleRandomMapToggle() {
this.useRandomMap = true;
this.putGameConfig();
}
private async handleMapSelection(value: GameMapType) {
this.selectedMap = value;
this.useRandomMap = false;
@@ -586,14 +598,28 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
// Modified to include debouncing
private handleBotsChange(e: Event) {
const value = parseInt((e.target as HTMLInputElement).value);
if (isNaN(value) || value < 0 || value > 400) {
return;
}
// Update the display value immediately
this.bots = value;
this.putGameConfig();
// Clear any existing timer
if (this.botsUpdateTimer !== null) {
clearTimeout(this.botsUpdateTimer);
}
// Set a new timer to call putGameConfig after 300ms of inactivity
this.botsUpdateTimer = window.setTimeout(() => {
this.putGameConfig();
this.botsUpdateTimer = null;
}, 300);
}
private handleInstantBuildChange(e: Event) {
this.instantBuild = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
@@ -614,8 +640,9 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
const config = await getServerConfigFromClient();
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`,
{
method: "PUT",
headers: {
@@ -650,8 +677,9 @@ export class HostLobbyModal extends LitElement {
`Starting private game with map: ${GameMapType[this.selectedMap]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
);
this.close();
const config = await getServerConfigFromClient();
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/start_game/${this.lobbyId}`,
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
@@ -677,15 +705,13 @@ export class HostLobbyModal extends LitElement {
}
private async pollPlayers() {
fetch(
`/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
const config = await getServerConfigFromClient();
fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
)
})
.then((response) => response.json())
.then((data: GameInfo) => {
console.log(`got response: ${data}`);
@@ -695,11 +721,11 @@ export class HostLobbyModal extends LitElement {
}
async function createLobby(): Promise<GameInfo> {
const serverConfig = getServerConfig();
const config = await getServerConfigFromClient();
try {
const id = generateID();
const response = await fetch(
`/${serverConfig.workerPath(id)}/create_game/${id}`,
`/${config.workerPath(id)}/api/create_game/${id}`,
{
method: "POST",
headers: {
+6 -4
View File
@@ -1,9 +1,9 @@
import { LitElement, css, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfig } from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameMapType, GameType } from "../core/game/Game";
import { GameInfo } from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/Config";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@@ -363,12 +363,13 @@ export class JoinPrivateLobbyModal extends LitElement {
}
}
private joinLobby() {
private async joinLobby() {
const lobbyId = this.lobbyIdInput.value;
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = "Checking lobby..."; // Set initial message
const url = `/${getServerConfig().workerPath(lobbyId)}/game/${lobbyId}/exists`;
const config = await getServerConfigFromClient();
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
fetch(url, {
method: "GET",
headers: {
@@ -406,9 +407,10 @@ export class JoinPrivateLobbyModal extends LitElement {
private async pollPlayers() {
if (!this.lobbyIdInput?.value) return;
const config = await getServerConfigFromClient();
fetch(
`/${getServerConfig().workerPath(this.lobbyIdInput.value)}/game/${this.lobbyIdInput.value}`,
`/${config.workerPath(this.lobbyIdInput.value)}/api/game/${this.lobbyIdInput.value}`,
{
method: "GET",
headers: {
+2 -7
View File
@@ -1,9 +1,4 @@
import {
Config,
GameEnv,
getServerConfig,
ServerConfig,
} from "../core/configuration/Config";
import { Config, GameEnv, ServerConfig } from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameEvent } from "../core/EventBus";
import {
@@ -130,7 +125,7 @@ export class LocalServer {
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
const workerPath = getServerConfig().workerPath(this.lobbyConfig.gameID);
const workerPath = this.serverConfig.workerPath(this.lobbyConfig.gameID);
navigator.sendBeacon(`/${workerPath}/archive_singleplayer_game`, blob);
}
}
+3
View File
@@ -20,6 +20,7 @@ import { DarkModeButton } from "./DarkModeButton";
import "./GoogleAdElement";
import { HelpModal } from "./HelpModal";
import { GameType } from "../core/game/Game";
import { getServerConfigFromClient } from "../core/configuration/Config";
class Client {
private gameStop: () => void;
@@ -136,9 +137,11 @@ class Client {
consolex.log("joining lobby, stopping existing game");
this.gameStop();
}
const config = await getServerConfigFromClient();
const gameType = event.detail.gameType;
this.gameStop = joinLobby(
{
serverConfig: config,
gameType: gameType,
flag: (): string =>
this.flagInput.getCurrentFlag() == "xx"
+2 -1
View File
@@ -38,6 +38,7 @@ export class PublicLobby extends LitElement {
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
console.log(`got lobbies: ${JSON.stringify(lobbies)}`);
this.lobbies = lobbies;
} catch (error) {
consolex.error("Error fetching lobbies:", error);
@@ -46,7 +47,7 @@ export class PublicLobby extends LitElement {
async fetchLobbies(): Promise<GameInfo[]> {
try {
const response = await fetch(`/public_lobbies`);
const response = await fetch(`/api/public_lobbies`);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
-1
View File
@@ -228,7 +228,6 @@ export class WinModal extends LitElement implements Layer {
this.won = false;
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView;
this.eventBus.emit(new SendWinnerEvent(winner.clientID()));
+1 -1
View File
@@ -32,7 +32,7 @@ export async function createGameRunner(
clientID: ClientID,
callBack: (gu: GameUpdateViewData) => void,
): Promise<GameRunner> {
const config = getConfig(gameConfig, null);
const config = await getConfig(gameConfig, null);
const gameMap = await loadGameMap(gameConfig.gameMap);
const game = createGame(
gameMap.gameMap,
+42 -16
View File
@@ -22,21 +22,24 @@ import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { UserSettings } from "../game/UserSettings";
let cachedSC: ServerConfig = null;
export enum GameEnv {
Dev,
Preprod,
Prod,
}
export function getConfig(
export async function getConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null = null,
): Config {
const sc = getServerConfig();
switch (process.env.GAME_ENV) {
case "dev":
): Promise<Config> {
const sc = await getServerConfigFromClient();
switch (sc.env()) {
case GameEnv.Dev:
return new DevConfig(sc, gameConfig, userSettings);
case "preprod":
case "prod":
case GameEnv.Preprod:
case GameEnv.Prod:
consolex.log("using prod config");
return new DefaultConfig(sc, gameConfig, userSettings);
default:
@@ -44,20 +47,43 @@ export function getConfig(
}
}
export function getServerConfig(): ServerConfig {
switch (process.env.GAME_ENV) {
export async function getServerConfigFromClient(): Promise<ServerConfig> {
if (cachedSC) {
return cachedSC;
}
const response = await fetch("/api/env");
if (!response.ok) {
throw new Error(
`Failed to fetch server config: ${response.status} ${response.statusText}`,
);
}
const config = await response.json();
// Log the retrieved configuration
console.log("Server config loaded:", config);
cachedSC = getServerConfig(config.game_env);
return cachedSC;
}
export function getServerConfigFromServer(): ServerConfig {
const gameEnv = process.env.GAME_ENV;
return getServerConfig(gameEnv);
}
function getServerConfig(gameEnv: string) {
switch (gameEnv) {
case "dev":
consolex.log("using dev config");
consolex.log("using dev server config");
return new DevServerConfig();
case "preprod":
consolex.log("using preprod config");
case "staging":
consolex.log("using preprod server config");
return preprodConfig;
case "prod":
default:
consolex.log("using prod config");
consolex.log("using prod server config");
return prodConfig;
// default:
// throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`)
default:
throw Error(`unsupported server configuration: ${gameEnv}`);
}
}
+3 -5
View File
@@ -28,9 +28,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
adminToken(): string {
return process.env.ADMIN_TOKEN;
}
numWorkers(): number {
return 2;
}
abstract numWorkers(): number;
abstract env(): GameEnv;
abstract discordRedirectURI(): string;
turnIntervalMs(): number {
@@ -38,9 +36,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
gameCreationRate(highTraffic: boolean): number {
if (highTraffic) {
return 30 * 1000;
return 20 * 1000;
} else {
return 60 * 1000;
return 50 * 1000;
}
}
lobbyLifetime(highTraffic: boolean): number {
+3
View File
@@ -20,6 +20,9 @@ export class DevServerConfig extends DefaultServerConfig {
discordRedirectURI(): string {
return "http://localhost:3000/auth/callback";
}
numWorkers(): number {
return 2;
}
}
export class DevConfig extends DefaultConfig {
+3
View File
@@ -8,4 +8,7 @@ export const preprodConfig = new (class extends DefaultServerConfig {
discordRedirectURI(): string {
return "https://openfront.dev/auth/callback";
}
numWorkers(): number {
return 3;
}
})();
+3
View File
@@ -2,6 +2,9 @@ import { GameEnv } from "./Config";
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
numWorkers(): number {
return 6;
}
env(): GameEnv {
return GameEnv.Prod;
}
+52 -51
View File
@@ -1,27 +1,29 @@
import { GameRecord, GameID } from "../core/Schemas";
import { S3 } from "@aws-sdk/client-s3";
import { RedshiftData } from "@aws-sdk/client-redshift-data";
import {
GameEnv,
getServerConfigFromServer,
} from "../core/configuration/Config";
// Initialize AWS clients
const s3 = new S3();
const bucket = "openfront-games";
const redshiftData = new RedshiftData({ region: "eu-west-1" });
const config = getServerConfigFromServer();
// Redshift Serverless configuration
const REDSHIFT_WORKGROUP = "game-analytics";
const REDSHIFT_DATABASE = "game_archive";
const s3 = new S3({ region: "eu-west-1" });
const gameBucket = "openfront-games";
const analyticsBucket = "openfront-analytics";
export async function archive(gameRecord: GameRecord) {
try {
// Archive to Redshift Serverless
await archiveToRedshift(gameRecord);
await archiveAnalyticsToS3(gameRecord);
// Archive to S3 if there are turns
if (gameRecord.turns.length > 0) {
console.log(
`${gameRecord.id}: game has more than zero turns, attempting to write to S3`,
`${gameRecord.id}: game has more than zero turns, attempting to write to full game to S3`,
);
await archiveToS3(gameRecord);
await archiveFullGameToS3(gameRecord);
}
} catch (error) {
console.error(`${gameRecord.id}: Final archive error: ${error}`, {
@@ -33,55 +35,54 @@ export async function archive(gameRecord: GameRecord) {
}
}
async function archiveToRedshift(gameRecord: GameRecord) {
const row = {
async function archiveAnalyticsToS3(gameRecord: GameRecord) {
// Create analytics data object (similar to what was going to Redshift)
const analyticsData = {
id: gameRecord.id,
start: new Date(gameRecord.startTimestampMS),
end: new Date(gameRecord.endTimestampMS),
env: config.env(),
start_time: new Date(gameRecord.startTimestampMS).toISOString(),
end_time: new Date(gameRecord.endTimestampMS).toISOString(),
duration_seconds: gameRecord.durationSeconds,
number_turns: gameRecord.num_turns,
game_mode: gameRecord.gameConfig.gameType,
winner: gameRecord.winner,
difficulty: gameRecord.gameConfig.difficulty,
map: gameRecord.gameConfig.gameMap,
players: JSON.stringify(
gameRecord.players.map((p) => ({
username: p.username,
ip: p.ip,
persistentID: p.persistentID,
clientID: p.clientID,
})),
),
mapType: gameRecord.gameConfig.gameMap,
players: gameRecord.players.map((p) => ({
username: p.username,
ip: p.ip,
persistentID: p.persistentID,
clientID: p.clientID,
})),
};
// Convert the row to SQL parameters for insertion
const params = {
Sql: `
INSERT INTO game_results (
id, start, end, duration_seconds, number_turns, game_mode,
winner, difficulty, map, players
) VALUES (
'${row.id}',
'${row.start.toISOString()}',
'${row.end.toISOString()}',
${row.duration_seconds},
${row.number_turns},
'${row.game_mode}',
'${row.winner}',
'${row.difficulty}',
'${row.map}',
JSON_PARSE('${row.players}')
)
`,
WorkgroupName: REDSHIFT_WORKGROUP,
Database: REDSHIFT_DATABASE,
};
try {
// Store analytics data using just the game ID as the key
const analyticsKey = `${gameRecord.id}.json`;
await redshiftData.executeStatement(params);
console.log(`${gameRecord.id}: wrote game metadata to Redshift`);
await s3.putObject({
Bucket: analyticsBucket,
Key: analyticsKey,
Body: JSON.stringify(analyticsData),
ContentType: "application/json",
});
console.log(`${gameRecord.id}: successfully wrote game analytics to S3`);
} catch (error) {
console.error(
`${gameRecord.id}: Error writing game analytics to S3: ${error}`,
{
message: error?.message || error,
stack: error?.stack,
name: error?.name,
...(error && typeof error === "object" ? error : {}),
},
);
throw error;
}
}
async function archiveToS3(gameRecord: GameRecord) {
async function archiveFullGameToS3(gameRecord: GameRecord) {
// Create a deep copy to avoid modifying the original
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
@@ -93,7 +94,7 @@ async function archiveToS3(gameRecord: GameRecord) {
try {
await s3.putObject({
Bucket: bucket,
Bucket: gameBucket,
Key: recordCopy.id,
Body: JSON.stringify(recordCopy),
ContentType: "application/json",
@@ -110,7 +111,7 @@ export async function readGameRecord(gameId: GameID): Promise<GameRecord> {
try {
// Check if file exists and download in one operation
const response = await s3.getObject({
Bucket: bucket,
Bucket: gameBucket,
Key: gameId,
});
@@ -133,7 +134,7 @@ export async function readGameRecord(gameId: GameID): Promise<GameRecord> {
export async function gameRecordExists(gameId: GameID): Promise<boolean> {
try {
await s3.headObject({
Bucket: bucket,
Bucket: gameBucket,
Key: gameId,
});
return true;
+45 -10
View File
@@ -4,7 +4,10 @@ import express from "express";
import { GameMapType, GameType, Difficulty } from "../core/game/Game";
import { generateID } from "../core/Util";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameEnv, getServerConfig } from "../core/configuration/Config";
import {
GameEnv,
getServerConfigFromServer,
} from "../core/configuration/Config";
import { GameInfo } from "../core/Schemas";
import path from "path";
import rateLimit from "express-rate-limit";
@@ -12,7 +15,7 @@ import { fileURLToPath } from "url";
import { isHighTrafficTime } from "./Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
const config = getServerConfig();
const config = getServerConfigFromServer();
const readyWorkers = new Set();
const app = express();
@@ -21,8 +24,25 @@ const server = http.createServer(app);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, "../../out")));
app.use(
express.static(path.join(__dirname, "../../static"), {
maxAge: "1y", // Set max-age to 1 year for all static assets
setHeaders: (res, path) => {
// You can conditionally set different cache times based on file types
if (path.endsWith(".html")) {
// HTML files get shorter cache time
res.setHeader("Cache-Control", "public, max-age=60");
} else if (path.match(/\.(js|css|svg)$/)) {
// JS, CSS, SVG get long cache with immutable
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
} else if (path.match(/\.(bin|dat|exe|dll|so|dylib)$/)) {
// Binary files also get long cache with immutable
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
// Other file types use the default maxAge setting
},
}),
);
app.use(express.json());
app.set("trust proxy", 3);
@@ -122,9 +142,19 @@ export async function startMaster() {
});
}
app.get(
"/api/env",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const envConfig = {
game_env: process.env.GAME_ENV || "prod",
};
res.json(envConfig);
}),
);
// Add lobbies endpoint to list public games for this worker
app.get(
"/public_lobbies",
"/api/public_lobbies",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
res.send(publicLobbiesJsonStr);
}),
@@ -135,7 +165,7 @@ async function fetchLobbies(): Promise<void> {
for (const gameID of publicLobbyIDs) {
const port = config.workerPort(gameID);
const promise = fetch(`http://localhost:${port}/game/${gameID}`)
const promise = fetch(`http://localhost:${port}/api/game/${gameID}`)
.then((resp) => resp.json())
.then((json) => {
return json as GameInfo;
@@ -191,17 +221,18 @@ async function schedulePublicGame() {
disableNPCs: false,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/create_game/${gameID}`,
`http://localhost:${config.workerPort(gameID)}/api/create_game/${gameID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Request": "true",
[config.adminHeader()]: config.adminToken(),
"X-Internal-Request": "true", // Special header for internal requests
},
body: JSON.stringify({
gameID: gameID,
@@ -209,9 +240,11 @@ async function schedulePublicGame() {
}),
},
);
if (!response.ok) {
throw new Error(`Failed to schedule public game: ${response.statusText}`);
}
const data = await response.json();
} catch (error) {
console.error(
@@ -222,9 +255,11 @@ async function schedulePublicGame() {
}
}
// Map rotation management (moved from GameManager)
let mapsPlaylist: GameMapType[] = [];
const random = new PseudoRandom(123);
// Get the next map in rotation
function getNextMap(): GameMapType {
if (mapsPlaylist.length > 0) {
return mapsPlaylist.shift()!;
@@ -275,5 +310,5 @@ function sleep(ms: number): Promise<void> {
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../out/index.html"));
res.sendFile(path.join(__dirname, "../../static/index.html"));
});
+3
View File
@@ -0,0 +1,3 @@
# Gatekeeper
Security module for botting, rate limiting, fingerprinting, etc.
+34 -13
View File
@@ -4,7 +4,7 @@ import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import { getServerConfig } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/Config";
import { WebSocket } from "ws";
import { Client } from "./Client";
import rateLimit from "express-rate-limit";
@@ -13,9 +13,9 @@ import { GameConfig, GameRecord, LogSeverity } from "../core/Schemas";
import { slog } from "./StructuredLog";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
import { LimiterType, gatekeeper } from "./Gatekeeper";
import { gatekeeper, LimiterType } from "./Gatekeeper";
const config = getServerConfig();
const config = getServerConfigFromServer();
// Worker setup
export function startWorker() {
@@ -69,7 +69,7 @@ export function startWorker() {
// Endpoint to create a private lobby
app.post(
"/create_game/:id",
"/api/create_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const id = req.params.id;
if (!id) {
@@ -79,9 +79,9 @@ export function startWorker() {
// TODO: if game is public make sure request came from localhohst!!!
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const gc = req.body?.gameConfig as GameConfig;
if (gc?.gameType == GameType.Public && !isAdmin(req)) {
if (gc?.gameType == GameType.Public && !isLocalhost(req)) {
console.warn(
`cannot create public game ${id}, ip ${clientIP} not admin`,
`cannot create public game ${id}, ip ${clientIP} not localhost`,
);
return res.status(400);
}
@@ -106,7 +106,7 @@ export function startWorker() {
// Add other endpoints from your original server
app.post(
"/start_game/:id",
"/api/start_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`);
const game = gm.game(req.params.id);
@@ -126,7 +126,7 @@ export function startWorker() {
);
app.put(
"/game/:id",
"/api/game/:id",
gatekeeper.httpHandler(LimiterType.Put, async (req, res) => {
// TODO: only update public game if from local host
const lobbyID = req.params.id;
@@ -157,7 +157,7 @@ export function startWorker() {
);
app.get(
"/game/:id/exists",
"/api/game/:id/exists",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const lobbyId = req.params.id;
res.json({
@@ -167,7 +167,7 @@ export function startWorker() {
);
app.get(
"/game/:id",
"/api/game/:id",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
@@ -179,7 +179,7 @@ export function startWorker() {
);
app.post(
"/archive_singleplayer_game",
"/api/archive_singleplayer_game",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
@@ -320,6 +320,27 @@ export function startWorker() {
});
}
const isAdmin = (req: Request): boolean => {
return req.headers[config.adminHeader()] === config.adminToken();
const isLocalhost = (req: Request): boolean => {
// Get client IP address from various possible sources
const clientIP =
req.ip ||
req.socket.remoteAddress ||
(req.headers["x-forwarded-for"] as string)?.split(",").shift() ||
"unknown";
// Check if the request is from a loopback address
const isLoopbackIP =
// IPv4 localhost
clientIP === "127.0.0.1" ||
// IPv6 localhost
clientIP === "::1" ||
// Full loopback range
clientIP.startsWith("127.");
// Check hostname
const isLocalHostname =
req.hostname === "localhost" || req.headers.host?.startsWith("localhost:");
// Consider request local if either IP is loopback or hostname is localhost
return isLoopbackIP || isLocalHostname;
};