server.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import { createServer } from 'node:http';
  2. import crypto from 'node:crypto';
  3. import fs from 'node:fs';
  4. import path from 'node:path';
  5. import { fileURLToPath } from 'node:url';
  6. import express from 'express';
  7. import { Server } from 'socket.io';
  8. import winston from 'winston';
  9. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  10. // ***************************************************************************
  11. // Config
  12. // ***************************************************************************
  13. // Everything comes from the environment -- see .env.example. `npm run dev`
  14. // loads .env; in production the service manager passes real env vars. This
  15. // replaces the old client-side ENV switch in public/javascripts/app.js, which
  16. // hardcoded chess.davidawindham.com:8181 and had to be hand-edited per host.
  17. const config = {
  18. port: Number(process.env.PORT ?? 8181),
  19. // '' when served at the domain root, '/chess' when mounted at a subpath.
  20. basePath: normalizeBasePath(process.env.BASE_PATH ?? ''),
  21. // Number of reverse proxies in front of us, or a preset like 'loopback'.
  22. trustProxy: process.env.TRUST_PROXY ?? '',
  23. logFile: process.env.LOG_FILE ?? path.join(__dirname, 'logs/games.log'),
  24. // Local dev only. See the /embed proxy below.
  25. dawOrigin: process.env.DAW_ORIGIN ?? '',
  26. };
  27. function normalizeBasePath(value) {
  28. const trimmed = value.trim().replace(/\/+$/, '');
  29. if (!trimmed) return '';
  30. return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
  31. }
  32. // A game link is the only thing gating access to a game, so tokens are the
  33. // closest thing this app has to a credential. Shape is fixed by makeToken:
  34. // 15 random bytes as base64url = 20 chars from [A-Za-z0-9_-].
  35. const TOKEN_RE = /^[A-Za-z0-9_-]{20}$/;
  36. // Mirrors the bounds the form enforces client-side (views/index.pug).
  37. const MIN_MINUTES = 1;
  38. const MAX_MINUTES = 50;
  39. const MAX_INCREMENT = 50;
  40. const TOKEN_TTL_MS = 5 * 60 * 1000;
  41. // ***************************************************************************
  42. // Logging
  43. // ***************************************************************************
  44. fs.mkdirSync(path.dirname(config.logFile), { recursive: true });
  45. const format = winston.format.combine(winston.format.timestamp(), winston.format.simple());
  46. // Operational messages -- boot, config, the embed proxy. Console only, so the
  47. // service manager collects them. Deliberately kept out of games.log: that file
  48. // is world-readable over GET /logs, and this is where the port and DAW_ORIGIN
  49. // would otherwise leak into it.
  50. const logger = winston.createLogger({
  51. level: 'info',
  52. format,
  53. transports: [new winston.transports.Console()],
  54. });
  55. // games.log, which GET /logs serves. Winston had no file transport at all
  56. // before, so the file was never written and that route could only ever reach
  57. // its error path. Game counts only -- see the note on the route.
  58. const gameLogger = winston.createLogger({
  59. level: 'info',
  60. format,
  61. transports: [
  62. new winston.transports.File({ filename: config.logFile }),
  63. new winston.transports.Console(),
  64. ],
  65. });
  66. // ***************************************************************************
  67. // App
  68. // ***************************************************************************
  69. const app = express();
  70. const server = createServer(app);
  71. if (config.trustProxy) {
  72. // Needed for correct client IPs / protocol when Apache fronts us at /chess.
  73. const asNumber = Number(config.trustProxy);
  74. app.set('trust proxy', Number.isNaN(asNumber) ? config.trustProxy : asNumber);
  75. }
  76. app.set('views', path.join(__dirname, 'views'));
  77. app.set('view engine', 'pug');
  78. // Templates build every asset URL from this, so the same views work whether the
  79. // app is at the domain root or under /chess.
  80. app.locals.basePath = config.basePath;
  81. const router = express.Router();
  82. router.use(express.static(path.join(__dirname, 'public')));
  83. router.get('/', (req, res) => {
  84. res.render('index');
  85. });
  86. router.get('/about', (req, res) => {
  87. res.render('about');
  88. });
  89. // The three params land in a <script> block in views/play.pug, so they are
  90. // validated here rather than escaped there: `time` and `increment` are numeric
  91. // in that context, and Pug's HTML escaping does nothing to a payload like
  92. // `1;alert(1)` that contains no HTML characters. Anything that isn't a
  93. // well-formed game link is simply not a game.
  94. router.get('/play/:token/:time/:increment', (req, res) => {
  95. const { token } = req.params;
  96. const time = Number(req.params.time);
  97. const increment = Number(req.params.increment);
  98. const valid =
  99. TOKEN_RE.test(token) &&
  100. Number.isInteger(time) &&
  101. time >= MIN_MINUTES &&
  102. time <= MAX_MINUTES &&
  103. Number.isInteger(increment) &&
  104. increment >= 0 &&
  105. increment <= MAX_INCREMENT;
  106. if (!valid) {
  107. res.sendStatus(404);
  108. return;
  109. }
  110. res.render('play', { token, time, increment });
  111. });
  112. // Served unauthenticated, so what gets written matters more than what gets read:
  113. // gameLogger is the only writer, and it records a running game count and nothing
  114. // else -- no addresses, no chat, no player identifiers. Keep it that way, or put
  115. // this route behind auth.
  116. router.get('/logs', async (req, res) => {
  117. try {
  118. res.type('text/plain').send(await fs.promises.readFile(config.logFile, 'utf8'));
  119. } catch {
  120. // The old version called res.redirect() without returning and then fell
  121. // through to res.send(data) with `data` undefined, so a single request to
  122. // /logs took the process down with ERR_HTTP_HEADERS_SENT. One send per path.
  123. res.redirect(`${config.basePath}/`);
  124. }
  125. });
  126. // Local dev only: serve the shared site chrome (/embed/chrome.js + its fonts)
  127. // by proxying to the main site.
  128. //
  129. // The layout loads /embed/chrome.js root-relative. In production Apache serves
  130. // that path from the WordPress docroot alongside /chess, and only routes
  131. // /chess here, so this never runs there. Locally, hitting node directly on
  132. // :8181 has no Apache in front, so without this the chrome would 404 and the
  133. // page would render bare. Unset DAW_ORIGIN => not mounted at all.
  134. if (config.dawOrigin) {
  135. app.use('/embed', async (req, res) => {
  136. try {
  137. const upstream = await fetch(new URL(`/embed${req.url}`, config.dawOrigin), {
  138. signal: AbortSignal.timeout(5000),
  139. });
  140. if (!upstream.ok) {
  141. res.sendStatus(upstream.status);
  142. return;
  143. }
  144. const type = upstream.headers.get('content-type');
  145. if (type) res.type(type);
  146. res.send(Buffer.from(await upstream.arrayBuffer()));
  147. } catch {
  148. res.sendStatus(502);
  149. }
  150. });
  151. logger.info('embed proxy enabled', { origin: config.dawOrigin });
  152. }
  153. // Canonicalize /chess -> /chess/ before the router sees it. Without the
  154. // trailing slash the browser resolves the page's relative asset URLs against
  155. // the parent directory, so every script and stylesheet 404s.
  156. //
  157. // The exact `req.path ===` test matters: Express's default non-strict routing
  158. // treats '/chess' and '/chess/' as the same route, so a plain app.get(basePath)
  159. // would also match the slashed URL and redirect it to itself, forever.
  160. if (config.basePath) {
  161. app.use((req, res, next) => {
  162. if (req.path === config.basePath) res.redirect(301, `${config.basePath}/`);
  163. else next();
  164. });
  165. }
  166. app.use(config.basePath || '/', router);
  167. // ***************************************************************************
  168. // Sockets
  169. // ***************************************************************************
  170. const io = new Server(server, {
  171. // Must line up with the client and with the proxy's websocket location.
  172. path: `${config.basePath}/socket.io`,
  173. });
  174. const games = {};
  175. // The old token was `new Buffer(Math.random() + Date.now() + socket.id)` sliced
  176. // out of base64 -- guessable, since Math.random() is not a CSPRNG and the other
  177. // two terms are largely knowable. Anyone who guesses a live token joins the
  178. // game, so this is the one place unpredictability actually matters.
  179. function makeToken() {
  180. return crypto.randomBytes(15).toString('base64url');
  181. }
  182. io.on('connection', (socket) => {
  183. socket.on('start', function () {
  184. const token = makeToken();
  185. // Token is valid for 5 minutes.
  186. const timeout = setTimeout(() => {
  187. // The game may already be gone -- disconnect deletes it without clearing
  188. // this timer, and the old body dereferenced games[token] unguarded.
  189. if (games[token]?.players.length === 0) {
  190. delete games[token];
  191. socket.emit('token-expired');
  192. }
  193. }, TOKEN_TTL_MS);
  194. games[token] = {
  195. creator: socket,
  196. players: [],
  197. interval: null,
  198. timeout,
  199. };
  200. socket.emit('created', { token });
  201. });
  202. socket.on('join', function (data) {
  203. let color;
  204. if (!(data.token in games)) {
  205. socket.emit('token-invalid');
  206. return;
  207. }
  208. clearTimeout(games[data.token].timeout);
  209. const game = games[data.token];
  210. if (game.players.length >= 2) {
  211. socket.emit('full');
  212. return;
  213. } else if (game.players.length === 1) {
  214. color = game.players[0].color === 'black' ? 'white' : 'black';
  215. gameLogger.info('Number of currently running games', { '#': Object.keys(games).length });
  216. } else {
  217. const colors = ['black', 'white'];
  218. color = colors[Math.floor(Math.random() * 2)];
  219. }
  220. socket.join(data.token);
  221. games[data.token].players.push({
  222. id: socket.id,
  223. socket,
  224. color,
  225. time: data.time - data.increment + 1,
  226. increment: data.increment,
  227. });
  228. game.creator.emit('ready', {});
  229. socket.emit('joined', { color });
  230. });
  231. socket.on('timer-white', function (data) {
  232. runTimer('white', data.token, socket);
  233. });
  234. socket.on('timer-black', function (data) {
  235. runTimer('black', data.token, socket);
  236. });
  237. socket.on('timer-clear-interval', function (data) {
  238. if (data.token in games) {
  239. clearInterval(games[data.token].interval);
  240. }
  241. });
  242. socket.on('new-move', function (data) {
  243. if (data.token in games) {
  244. const opponent = getOpponent(data.token, socket);
  245. if (opponent) {
  246. opponent.socket.emit('move', { move: data.move });
  247. }
  248. }
  249. });
  250. socket.on('resign', function (data) {
  251. if (data.token in games) {
  252. clearInterval(games[data.token].interval);
  253. io.in(data.token).emit('player-resigned', { color: data.color });
  254. }
  255. });
  256. socket.on('rematch-offer', function (data) {
  257. if (data.token in games) {
  258. const opponent = getOpponent(data.token, socket);
  259. if (opponent) {
  260. opponent.socket.emit('rematch-offered');
  261. }
  262. }
  263. });
  264. socket.on('rematch-decline', function (data) {
  265. if (data.token in games) {
  266. const opponent = getOpponent(data.token, socket);
  267. if (opponent) {
  268. opponent.socket.emit('rematch-declined');
  269. }
  270. }
  271. });
  272. socket.on('rematch-confirm', function (data) {
  273. if (data.token in games) {
  274. for (const j in games[data.token].players) {
  275. games[data.token].players[j].time = data.time - data.increment + 1;
  276. games[data.token].players[j].increment = data.increment;
  277. games[data.token].players[j].color =
  278. games[data.token].players[j].color === 'black' ? 'white' : 'black';
  279. }
  280. const opponent = getOpponent(data.token, socket);
  281. if (opponent) {
  282. io.in(data.token).emit('rematch-confirmed');
  283. }
  284. }
  285. });
  286. socket.on('disconnect', function () {
  287. for (const token in games) {
  288. const game = games[token];
  289. for (const j in game.players) {
  290. const player = game.players[j];
  291. if (player.socket === socket) {
  292. const opponent = game.players[Math.abs(j - 1)];
  293. if (opponent) {
  294. opponent.socket.emit('opponent-disconnected');
  295. }
  296. clearInterval(games[token].interval);
  297. clearTimeout(games[token].timeout);
  298. delete games[token];
  299. }
  300. }
  301. }
  302. });
  303. socket.on('send-message', function (data) {
  304. if (data.token in games) {
  305. const opponent = getOpponent(data.token, socket);
  306. if (opponent) {
  307. opponent.socket.emit('receive-message', data);
  308. }
  309. }
  310. });
  311. });
  312. function runTimer(color, token, socket) {
  313. const game = games[token];
  314. if (!game) return;
  315. for (const i in game.players) {
  316. const player = game.players[i];
  317. if (player.socket === socket && player.color === color) {
  318. clearInterval(games[token].interval);
  319. games[token].players[i].time += games[token].players[i].increment;
  320. return (games[token].interval = setInterval(() => {
  321. games[token].players[i].time -= 1;
  322. const timeLeft = games[token].players[i].time;
  323. if (timeLeft >= 0) {
  324. io.in(token).emit('countdown', { time: timeLeft, color });
  325. } else {
  326. io.in(token).emit('countdown-gameover', { color });
  327. clearInterval(games[token].interval);
  328. }
  329. }, 1000));
  330. }
  331. }
  332. }
  333. function getOpponent(token, socket) {
  334. const game = games[token];
  335. for (const j in game.players) {
  336. if (game.players[j].socket === socket) {
  337. return game.players[Math.abs(j - 1)];
  338. }
  339. }
  340. }
  341. server.listen(config.port, () => {
  342. logger.info('chess-io listening', {
  343. port: config.port,
  344. basePath: config.basePath || '/',
  345. });
  346. });