refacto: Replaced useless DB queries by websocket calls + patching WS auth-token leak
This commit is contained in:
@@ -11,7 +11,18 @@ const publicDir = path.join(__dirname, "..", "public");
|
||||
const app = express();
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN ?? "*" }));
|
||||
app.use(express.json());
|
||||
app.use(express.static(publicDir));
|
||||
app.use(express.static(publicDir, {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
setHeaders: (res) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
},
|
||||
}));
|
||||
|
||||
app.use("/api", (_req, res, next) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api", gameRouter);
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
setElementBonus,
|
||||
} from "./db/gameDb.js";
|
||||
import { computeTeamIncome, computeTeamElementBonus } from "./helpers/economy.js";
|
||||
import { buildRealtimeSnapshot } from "./realtimeSnapshot.js";
|
||||
import { broadcast } from "./ws/hub.js";
|
||||
|
||||
const TICK_SECONDS = 5;
|
||||
let lastTickSeed = null;
|
||||
|
||||
/**
|
||||
* Starts the server-side economy tick loop.
|
||||
@@ -19,6 +22,11 @@ export function startEconTick() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const worldSeed = await ensureSeedEpoch();
|
||||
if (lastTickSeed && lastTickSeed !== worldSeed) {
|
||||
broadcast("seed-changed", { worldSeed });
|
||||
}
|
||||
lastTickSeed = worldSeed;
|
||||
|
||||
const rows = await getGridCells(worldSeed);
|
||||
const cfg = getConfig();
|
||||
|
||||
@@ -41,6 +49,9 @@ export function startEconTick() {
|
||||
|
||||
await setElementBonus(worldSeed, "blue", blueBonus);
|
||||
await setElementBonus(worldSeed, "red", redBonus);
|
||||
|
||||
const snapshot = await buildRealtimeSnapshot(worldSeed);
|
||||
broadcast("snapshot", snapshot);
|
||||
} catch (e) {
|
||||
console.error("[econ tick]", e.message);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import "dotenv/config";
|
||||
import { createServer } from "http";
|
||||
import { loadConfigFile, getConfig } from "./configLoader.js";
|
||||
import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js";
|
||||
import { initUsersSchema } from "./db/usersDb.js";
|
||||
import app from "./app.js";
|
||||
import { startEconTick } from "./econTick.js";
|
||||
import { initWebSocketHub, broadcast } from "./ws/hub.js";
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8080);
|
||||
let lastConfigSignature = "";
|
||||
|
||||
function makeConfigSignature(cfg) {
|
||||
return JSON.stringify({
|
||||
dailyActionQuota: cfg.dailyActionQuota,
|
||||
teamActionQuota: cfg.teamActionQuota,
|
||||
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
|
||||
debugModeForTeams: cfg.debugModeForTeams,
|
||||
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||
elementWorth: cfg.elementWorth ?? {},
|
||||
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
||||
militaryPower: cfg.militaryPower ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Config-file poll ──────────────────────────────────────────────────────────
|
||||
// Periodically re-reads game.settings.json and checks for a seed-epoch change.
|
||||
@@ -15,8 +31,27 @@ function scheduleConfigPoll() {
|
||||
const ms = Math.max(5_000, getConfig().configReloadIntervalSeconds * 1_000);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const beforeSig = lastConfigSignature;
|
||||
loadConfigFile();
|
||||
await ensureSeedEpoch();
|
||||
const worldSeed = await ensureSeedEpoch();
|
||||
const cfg = getConfig();
|
||||
const nextSig = makeConfigSignature(cfg);
|
||||
if (beforeSig && nextSig !== beforeSig) {
|
||||
broadcast("config-updated", {
|
||||
worldSeed,
|
||||
config: {
|
||||
dailyActionQuota: cfg.dailyActionQuota,
|
||||
teamActionQuota: cfg.teamActionQuota,
|
||||
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
|
||||
debugModeForTeams: cfg.debugModeForTeams,
|
||||
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||
elementWorth: cfg.elementWorth ?? {},
|
||||
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
||||
militaryPower: cfg.militaryPower ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
lastConfigSignature = nextSig;
|
||||
} catch (e) {
|
||||
console.error("[config poll]", e.message);
|
||||
}
|
||||
@@ -31,8 +66,12 @@ async function main() {
|
||||
await initGameSchema();
|
||||
await initUsersSchema();
|
||||
await ensureSeedEpoch();
|
||||
lastConfigSignature = makeConfigSignature(getConfig());
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const httpServer = createServer(app);
|
||||
initWebSocketHub(httpServer);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
const cfg = getConfig();
|
||||
console.log(
|
||||
`[server] Listening on :${PORT} dailyQuota=${cfg.dailyActionQuota} wipe=${cfg.databaseWipeoutIntervalSeconds}s`
|
||||
|
||||
26
server/realtimeSnapshot.js
Normal file
26
server/realtimeSnapshot.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
getEconScores,
|
||||
getElementBonus,
|
||||
getMilitaryDeductions,
|
||||
getActivePlayerCounts,
|
||||
getVictoryPoints,
|
||||
} from "./db/gameDb.js";
|
||||
|
||||
export async function buildRealtimeSnapshot(worldSeed) {
|
||||
const [scores, elementBonus, militaryDeductions, activePlayers, victoryPoints] = await Promise.all([
|
||||
getEconScores(worldSeed),
|
||||
getElementBonus(worldSeed),
|
||||
getMilitaryDeductions(worldSeed),
|
||||
getActivePlayerCounts(worldSeed),
|
||||
getVictoryPoints(),
|
||||
]);
|
||||
|
||||
return {
|
||||
worldSeed,
|
||||
scores,
|
||||
elementBonus,
|
||||
militaryDeductions,
|
||||
activePlayers,
|
||||
victoryPoints,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "../db/usersDb.js";
|
||||
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
|
||||
import { computeTeamMilitaryPower } from "../helpers/economy.js";
|
||||
import { broadcast, broadcastToTeam } from "../ws/hub.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -95,7 +96,7 @@ router.get("/config", async (req, res) => {
|
||||
databaseWipeoutIntervalSeconds: rot,
|
||||
debugModeForTeams: cfg.debugModeForTeams,
|
||||
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||
worldSeed: ws.worldSeed,
|
||||
worldSeed,
|
||||
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
||||
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
||||
actionsRemaining,
|
||||
@@ -193,6 +194,11 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
|
||||
const existing = await getExistingCell(seed, x, y);
|
||||
if (!existing) return res.status(500).json({ error: "insert_race" });
|
||||
|
||||
broadcastToTeam(team, "cell-updated", {
|
||||
worldSeed,
|
||||
cell: rowToCellPayload(existing),
|
||||
});
|
||||
|
||||
return res.json(rowToCellPayload(existing));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -258,10 +264,32 @@ router.post("/cell/capture", authMiddleware, async (req, res) => {
|
||||
|
||||
const updatedCell = await getExistingCell(worldSeed, x, y);
|
||||
const updatedTeamRow = await getTeamActionsRow(team);
|
||||
const updatedCellPayload = rowToCellPayload(updatedCell);
|
||||
|
||||
// Team that made the capture always gets the update.
|
||||
broadcastToTeam(team, "cell-updated", {
|
||||
worldSeed,
|
||||
cell: updatedCellPayload,
|
||||
});
|
||||
|
||||
// Opponent receives the update only if that team had visibility on this cell.
|
||||
const opposingTeam = team === "blue" ? "red" : "blue";
|
||||
const opposingVisible = await checkTeamVisibility(worldSeed, opposingTeam, x, y);
|
||||
if (opposingVisible) {
|
||||
broadcastToTeam(opposingTeam, "cell-updated", {
|
||||
worldSeed,
|
||||
cell: updatedCellPayload,
|
||||
});
|
||||
}
|
||||
|
||||
broadcastToTeam(team, "team-quota-updated", {
|
||||
team,
|
||||
actionsRemaining: updatedTeamRow?.actions_remaining ?? null,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cell: rowToCellPayload(updatedCell),
|
||||
cell: updatedCellPayload,
|
||||
teamActionsRemaining: updatedTeamRow?.actions_remaining ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -476,10 +504,30 @@ router.post("/military/attack", authMiddleware, async (req, res) => {
|
||||
|
||||
const deductions = await getMilitaryDeductions(worldSeed);
|
||||
const updatedCell = await getExistingCell(worldSeed, x, y);
|
||||
const updatedCellPayload = rowToCellPayload(updatedCell);
|
||||
|
||||
broadcast("military-deductions-updated", {
|
||||
worldSeed,
|
||||
deductions,
|
||||
});
|
||||
|
||||
broadcastToTeam(attackingTeam, "cell-updated", {
|
||||
worldSeed,
|
||||
cell: updatedCellPayload,
|
||||
});
|
||||
|
||||
const opposingTeam = attackingTeam === "blue" ? "red" : "blue";
|
||||
const opposingVisible = await checkTeamVisibility(worldSeed, opposingTeam, x, y);
|
||||
if (opposingVisible) {
|
||||
broadcastToTeam(opposingTeam, "cell-updated", {
|
||||
worldSeed,
|
||||
cell: updatedCellPayload,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cell: rowToCellPayload(updatedCell),
|
||||
cell: updatedCellPayload,
|
||||
deductions,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
147
server/ws/hub.js
Normal file
147
server/ws/hub.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { JWT_SECRET } from "../middleware/auth.js";
|
||||
|
||||
let wss = null;
|
||||
let heartbeatTimer = null;
|
||||
|
||||
const clients = new Set();
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
function parseAuthFromToken(token) {
|
||||
try {
|
||||
if (!token) return null;
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
return {
|
||||
userId: payload.userId,
|
||||
username: payload.username,
|
||||
team: payload.team,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sendRaw(client, text) {
|
||||
if (client.ws.readyState !== WebSocket.OPEN) return;
|
||||
client.ws.send(text);
|
||||
}
|
||||
|
||||
function toWireMessage(type, payload = {}) {
|
||||
return JSON.stringify({
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupClient(client) {
|
||||
clients.delete(client);
|
||||
}
|
||||
|
||||
function parseClientMessage(data) {
|
||||
try {
|
||||
const text = typeof data === "string" ? data : data.toString("utf8");
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
if (heartbeatTimer) return;
|
||||
heartbeatTimer = setInterval(() => {
|
||||
for (const client of clients) {
|
||||
if (!client.isAlive) {
|
||||
try {
|
||||
client.ws.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cleanupClient(client);
|
||||
continue;
|
||||
}
|
||||
client.isAlive = false;
|
||||
try {
|
||||
client.ws.ping();
|
||||
} catch {
|
||||
cleanupClient(client);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
export function initWebSocketHub(httpServer) {
|
||||
if (wss) return wss;
|
||||
|
||||
wss = new WebSocketServer({
|
||||
server: httpServer,
|
||||
path: "/ws",
|
||||
});
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
const client = {
|
||||
ws,
|
||||
auth: null,
|
||||
isAlive: true,
|
||||
};
|
||||
clients.add(client);
|
||||
|
||||
ws.on("pong", () => {
|
||||
client.isAlive = true;
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
cleanupClient(client);
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
cleanupClient(client);
|
||||
});
|
||||
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (isBinary) return;
|
||||
const message = parseClientMessage(data);
|
||||
if (!message || message.type !== "auth") return;
|
||||
|
||||
client.auth = parseAuthFromToken(message.token);
|
||||
sendRaw(
|
||||
client,
|
||||
toWireMessage("auth-state", {
|
||||
authenticated: Boolean(client.auth),
|
||||
team: client.auth?.team ?? null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
sendRaw(
|
||||
client,
|
||||
toWireMessage("welcome", {
|
||||
authenticated: false,
|
||||
team: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
startHeartbeat();
|
||||
return wss;
|
||||
}
|
||||
|
||||
export function broadcast(type, payload = {}) {
|
||||
const text = toWireMessage(type, payload);
|
||||
for (const client of clients) {
|
||||
sendRaw(client, text);
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastToTeam(team, type, payload = {}) {
|
||||
const text = toWireMessage(type, payload);
|
||||
for (const client of clients) {
|
||||
if (client.auth?.team !== team) continue;
|
||||
sendRaw(client, text);
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnectedClientCount() {
|
||||
return clients.size;
|
||||
}
|
||||
Reference in New Issue
Block a user