From: Raphaël Jakse Date: Sun, 3 May 2020 11:06:52 +0000 (+0200) Subject: Fix scripts in bin/ to work with the public folder X-Git-Url: https://secure.softndesign.org/git/?a=commitdiff_plain;h=2b5182ac855feb5898203481aaf24450cb327903;p=trivabble.git Fix scripts in bin/ to work with the public folder --- diff --git a/.gitignore b/.gitignore index 3f44abf..cd8a3ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/games.backup.json +/server/games.backup.json /public/config.js diff --git a/bin/disable-const-let.sh b/bin/disable-const-let.sh index 1b9b510..4aa56f2 100755 --- a/bin/disable-const-let.sh +++ b/bin/disable-const-let.sh @@ -9,7 +9,7 @@ WORKING_DIRECTORY="$(dirname "$(dirname "$0")")" if [ -d "$WORKING_DIRECTORY/.git" ]; then if [ -z "$(git status --porcelain | grep '.js$')" ]; then - sed -i -E 's/(^|\(|\s)(let|const)(\s)/\1var\3/g' "$WORKING_DIRECTORY"/*.js + sed -i -E 's/(^|\(|\s)(let|const)(\s)/\1var\3/g' "$WORKING_DIRECTORY/public"/*.js else >&2 echo "You have untracked changes in JS files. I'm not going to mess up with your working copy." exit 1 diff --git a/bin/setup_prod.sh b/bin/setup_prod.sh index c8984e3..01ec820 100755 --- a/bin/setup_prod.sh +++ b/bin/setup_prod.sh @@ -2,6 +2,6 @@ exec "$(dirname "$0")/disable-const-let.sh" -if ! [ -f "$(dirname "$(dirname "$0")")/config.js" ]; then - touch "$(dirname "$(dirname "$0")")/config.js" +if ! [ -f "$(dirname "$(dirname "$0")")/public/config.js" ]; then + touch "$(dirname "$(dirname "$0")")/public/config.js" fi diff --git a/bin/upgrade-prod.sh b/bin/upgrade-prod.sh index 79ee061..b8e60a4 100755 --- a/bin/upgrade-prod.sh +++ b/bin/upgrade-prod.sh @@ -7,22 +7,56 @@ error() { exit 1 } -if [ -z "${1+x}" ]; then - error "Please provide the production directory in parameter." -fi +help() { + cat < + * + * @licstart + * This file is part of Trivabble. + * + * Trivabble is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License (GNU AGPL) + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Trivabble is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Trivabble. If not, see . + * @licend + * + * @source: https://trivabble.1s.fr/ + * @source: https://gitlab.com/raphj/trivabble/ + */ + +/*eslint strict: [2, "global"]*/ + +"use strict"; + +const port = parseInt(process.env.TRIVABBLE_PORT || "3000"); +const SAVE_TIMEOUT = 5000; +const KEEP_ALIVE = 30000; +const GAMES_BACKUP = process.env.TRIVABBLE_GAMES_BACKUP || "games.backup.json"; + +const VERSION = 202004281800; + +function envTrue(name) { + return (process.env[name] || "").toLowerCase() === "true"; +} + +const DEV_ENABLE_SERVING_FILES = envTrue("DEV_ENABLE_SERVING_FILES"); +const DEBUG_LOG = DEV_ENABLE_SERVING_FILES || envTrue("DEBUG_LOG"); + +if (DEV_ENABLE_SERVING_FILES) { + console.log("DEV_ENABLE_SERVING_FILES: Serving files in the current directory. Please never do this on a production server, this is for development purposes only."); +} + +const debuglog = DEBUG_LOG ? console.log.bind(console) : () => null; + +const http = require("http"); +const fs = require("fs"); +const crypto = require("crypto"); + +const REQUEST_TYPE_LONG_POLLING = 1; +const REQUEST_TYPE_SSE = 2; +const REQUEST_TYPE_WEBSOCKET = 3; + +const frBag = [ + " ", " ", // Jokers + "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", + "A", "A", "A", "A", "A", "A", "A", "A", "A", + "I", "I", "I", "I", "I", "I", "I", "I", + "N", "N", "N", "N", "N", "N", + "O", "O", "O", "O", "O", "O", + "R", "R", "R", "R", "R", "R", + "S", "S", "S", "S", "S", "S", + "T", "T", "T", "T", "T", "T", + "U", "U", "U", "U", "U", "U", + "L", "L", "L", "L", "L", + "D", "D", "D", + "M", "M", "M", + "G", "G", + "B", "B", + "C", "C", + "P", "P", + "F", "F", + "H", "H", + "V", "V", + "J", + "Q", + "K", + "W", + "X", + "Y", + "Z" +]; + +/* eslint-disable quote-props */ + +const frValues = { + " ": 0, + "E": 1, + "A": 1, + "I": 1, + "N": 1, + "O": 1, + "R": 1, + "S": 1, + "T": 1, + "U": 1, + "L": 1, + "D": 2, + "M": 2, + "G": 2, + "B": 3, + "C": 3, + "P": 3, + "F": 4, + "H": 4, + "V": 4, + "J": 8, + "Q": 8, + "K": 10, + "W": 10, + "X": 10, + "Y": 10, + "Z": 10 +}; + +/* eslint-enable quote-props */ + +const games = {}; + +let saveTimeout = null; +let running = true; +const server = http.createServer(handleRequest); +server.setTimeout(0); // The default in node 13 is 0. Earlier versions have 120. + +function saveGames(callback) { + fs.writeFile(GAMES_BACKUP, JSON.stringify(games), function (err) { + if (err) { + console.error("ERROR: Cannot save games!"); + } + + if (typeof callback === "function") { + return callback(err); + } + }); + + saveTimeout = null; +} + + +function shuffleInPlace(a) { + // https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array + + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const x = a[i]; + a[i] = a[j]; + a[j] = x; + } +} + +function Game() { + this.letterValues = frValues; + this.init(); + this.listeningPlayers = []; + this.pendingEvents = []; +} + +function writeMessage(responseAndType, data, terminate) { + if (!responseAndType[0]) { + return; + } + + if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET) { + webSocketWrite(responseAndType[0], data); + } else { + responseAndType[0][terminate ? "end" : "write"]( + responseAndType[1] === REQUEST_TYPE_SSE + ? "data:" + data + "\n\n" + : data.length + data + ); + } + + if (terminate) { + stopKeepAlive(responseAndType); + if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET && responseAndType[0] && !responseAndType[0].isDestroyed) { + responseAndType[0].end(webSocketCloseBuffer); + responseAndType[0] = null; + } + } else { + keepAlive(responseAndType); + } +} + +function stop() { + console.log("Saving games a first time"); + if (saveTimeout) { + clearTimeout(saveTimeout); + saveTimeout = null; + } + + saveGames(function () { + running = false; + console.log("Closing connections and saving the game state..."); + + let listeningPlayerCount = 0; + let gamesCount = 0; + + for (const gameID of Object.keys(games)) { + const game = games[gameID]; + for (const listeningPlayer of game.listeningPlayers) { + writeMessage(listeningPlayer, '{"stopping": 2000}', true); + } + + if (game.listeningPlayers.length) { + listeningPlayerCount += game.listeningPlayers.length; + gamesCount++; + } + } + + console.log( + "Stopped", gamesCount, "game" + (gamesCount === 1 ? "" : "s"), + "and", listeningPlayerCount, "player connection" + (listeningPlayerCount === 1 ? "" : "s") + + "." + ); + + server.close(saveGames); + }); +} + +const webSocketPingBuffer = Buffer.from([ + 0b10001001, 0b00000000 +]); + +const webSocketCloseBuffer = Buffer.from([ + 0b10001000, 0b00000000 +]); + + +function stopKeepAlive(responseAndType) { + if (responseAndType.keepAliveTimeout) { + clearTimeout(responseAndType.keepAliveTimeout); + responseAndType.keepAliveTimeout = 0; + } +} + +function keepAlive(responseAndType) { + stopKeepAlive(responseAndType); + responseAndType.keepAliveTimeout = setTimeout(function keepAliveTimeout() { + responseAndType[0].write( + responseAndType[1] === REQUEST_TYPE_WEBSOCKET + ? webSocketPingBuffer + : ( + responseAndType[1] === REQUEST_TYPE_SSE + ? ":\n\n" + : "2[]" + ) + ); + }, KEEP_ALIVE); +} + +function newBoard() { + const res = new Array(15 * 15); + + for (let i = 0; i < 15 * 15; i++) { + res[i] = ""; + } + + return res; +} + +Game.prototype.init = function () { + this.board = newBoard(); + this.bag = frBag.slice(); + this.racks = {}; + this.scores = {}; + this.lastUpdated = new Date(); + this.currentPlayer = ""; + + shuffleInPlace(this.bag); +}; + +Game.prototype.toJSON = function () { + return { + board: this.board, + bag: this.bag, + racks: this.racks, + scores: this.scores, + lastUpdated: this.lastUpdated.toISOString(), + currentPlayer: this.currentPlayer + }; +}; + +Game.fromJSON = function (obj) { + const game = new Game(); + game.board = obj.board || newBoard(); + game.bag = obj.bag || frBag.slice(); + game.racks = obj.racks || {}; + game.scores = obj.scores || {}; + game.lastUpdated = obj.lastUpdated ? new Date(obj.lastUpdated) : new Date(); + game.currentPlayer = obj.currentPlayer || ""; + return game; +}; + +Game.prototype.getPlayerRack = function (player) { + const playerID = "#" + player; + return (this.racks[playerID] || (this.racks[playerID] = [])); +}; + +Game.prototype.getPlayerScore = function (player) { + const playerID = "#" + player; + return (this.scores[playerID] || (this.scores[playerID] = 0)); +}; + +Game.prototype.setPlayerScore = function (player, score) { + const playerID = "#" + player; + + if (!Object.prototype.hasOwnProperty.call(this.racks, playerID) || typeof score !== "number") { + return; + } + + this.scores[playerID] = score; + + this.pendingEvents.push({ + players: [{ + player: player, + score: score + }] + }); +}; + +Game.prototype.setCurrentPlayer = function (player) { + this.currentPlayer = player; + this.pendingEvents.push({currentPlayer: player}); +}; + +Game.prototype.playerJoined = function (playerName) { + if (playerName) { + this.getPlayerRack(playerName); // Create the player's rack + } + + const players = []; + + for (let player in this.racks) { + if (Object.prototype.hasOwnProperty.call(this.racks, player)) { + player = player.slice(1); // '#' + players.push({ + player: player, + score: this.getPlayerScore(player), + rackCount: countTiles(this.getPlayerRack(player)) + }); + } + } + + this.pendingEvents.push({players: players}); +}; + +Game.prototype.addListeningPlayer = function (playerName, responseAndType) { + const that = this; + + that.listeningPlayers.push(responseAndType); + keepAlive(responseAndType); + + let closed = false; + + function close () { + if (closed) { + return; + } + + closed = true; + stopKeepAlive(responseAndType); + responseAndType[0] = null; + + const index = that.listeningPlayers.indexOf(responseAndType); + if (index !== -1) { + that.listeningPlayers[index] = that.listeningPlayers[that.listeningPlayers.length - 1]; + that.listeningPlayers.pop(); + } + } + + responseAndType[0].on("error", close); + responseAndType[0].on("close", close); + responseAndType[0].on("finish", close); + responseAndType[0].on("prefinish", close); + + this.playerJoined(playerName); + this.commit(); +}; + +Game.prototype.commit = function () { + const msg = JSON.stringify(this.pendingEvents); + this.pendingEvents = []; + + for (let i = 0; i < this.listeningPlayers.length; i++) { + while (i < this.listeningPlayers.length && !this.listeningPlayers[i]) { + this.listeningPlayers[i] = this.listeningPlayers[this.listeningPlayers.length - 1]; + this.listeningPlayers.pop(); + } + + if (this.listeningPlayers[i]) { + writeMessage(this.listeningPlayers[i], msg); + } + } + + if (saveTimeout === null) { + saveTimeout = setTimeout(saveGames, SAVE_TIMEOUT); + } +}; + +Game.prototype.bagPopLetter = function (player) { + if (this.bag.length) { + const letter = this.bag.pop(); + this.pendingEvents.push({ + player: player, + action: "popBag", + remainingLetters: this.bag.length + }); + + return letter; + } + + return ""; +}; + +Game.prototype.getCell = function (index) { + return this.board[index]; +}; + +Game.prototype.setCell = function (index, letter, player) { + this.board[index] = letter; + this.pendingEvents.push({ + player: player, + action: "setCell", + indexTo: index, + letter: letter + }); +}; + +Game.prototype.bagPushLetter = function (letter, player) { + if (letter) { + this.bag.push(letter); + shuffleInPlace(this.bag); + + this.pendingEvents.push({ + player: player, + action: "pushBag", + remainingLetters: this.bag.length + }); + } +}; + +Game.prototype.reset = function (player) { + this.init(); + this.pendingEvents.push({ + player: player, + action: "reset", + board: this.board, + remainingLetters: this.bag.length, + rack: [] + }); + + this.playerJoined(); +}; + +function newGameId() { + let number; + + let k = 10000; + let retries = 0; + + do { + number = Math.floor(Math.random() * k).toString(); + + if (retries > 10) { + retries = 0; + k *= 10; + } else { + retries++; + } + } while (games[number]); + + return number.toString(); +} + +function countTiles(rack) { + let count = 0; + + for (let i = 0; i < rack.length; i++) { + if (rack[i]) { + count++; + } + } + + return count; +} + +function joinGame(gameNumber) { + if (!gameNumber) { + gameNumber = newGameId(); + } + + const game = games[gameNumber] || (games[gameNumber] = new Game()); + + return {gameNumber, game}; +} + +function reply(message, response, cmdNumber, r) { + response.write( + JSON.stringify({...r, id: message.id || message.cmds[cmdNumber].id}) + ); +} + +function handleCommand(cmdNumber, message, response) { + const {gameNumber, game} = joinGame(message.gameNumber); + + game.lastUpdated = new Date(); + + const playerName = message.playerName; + + let rack = null; + const cmd = message.cmds[cmdNumber]; + + switch (cmd.cmd) { + case "sync": // DEPRECATED. Here for old clients. + case "joinGame": { + reply(message, response, cmdNumber, { + error: 0, + gameNumber: gameNumber, + playerName: playerName, + currentPlayer: game.currentPlayer, + rack: game.getPlayerRack(playerName), + board: game.board, + remainingLetters: game.bag.length, + letterValues: frValues, + version: VERSION + }); + break; + } + + case "hello": { + game.playerJoined(playerName); + reply(message, response, cmdNumber, {error: 0, version: VERSION}); + break; + } + + case "score": { + game.setPlayerScore(cmd.player, cmd.score); + reply(message, response, cmdNumber, {error: 0}); + break; + } + + case "currentPlayer": { + game.setCurrentPlayer(cmd.player); + reply(message, response, cmdNumber, {error: 0}); + break; + } + + case "moveLetter": { + let letter = ""; + + // This case can fail in various ways. Instead altering the state + // of the game immediately, we store the operations to run them + // at the very end, if nothing failed. + const operations = []; + + switch (cmd.from) { + case "rack": + rack = game.getPlayerRack(playerName); + + if (cmd.indexFrom > 6 || cmd.indexFrom < 0) { + reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"}); + return false; + } + + letter = rack[cmd.indexFrom]; + operations.push(() => { + rack[cmd.indexFrom] = ""; + }); + break; + + case "board": + if (cmd.indexFrom < 0 || cmd.indexFrom >= 15 * 15) { + reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"}); + return false; + } + + letter = game.getCell(cmd.indexFrom); + operations.push(game.setCell.bind(game, cmd.indexFrom, "", playerName)); + break; + + case "bag": + if (!game.bag.length) { + reply(message, response, cmdNumber, {error: 1, reason: "Empty bag"}); + return false; + } + + operations.push(() => { + letter = game.bagPopLetter(playerName); + }); + break; + + default: + reply(message, response, cmdNumber, {error: 1, reason: "Moving letter from an unknown place"}); + return false; + } + + switch (cmd.to) { + case "rack": + if (cmd.indexTo < 0 || cmd.indexTo > 6) { + reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"}); + return false; + } + + rack = rack || game.getPlayerRack(playerName); + + if (rack[cmd.indexTo]) { + reply(message, response, cmdNumber, {error: 1, reason: "Moving a tile to a non-empty location"}); + return false; + } + + operations.push(() => { + rack[cmd.indexTo] = letter; + }); + break; + + case "board": + if (cmd.indexTo < 0 || cmd.indexTo >= 15 * 15) { + reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"}); + return false; + } + + operations.push(game.setCell.bind(game, cmd.indexTo, letter, playerName)); + break; + + case "bag": + operations.push(game.bagPushLetter.bind(game, letter, playerName)); + break; + + default: + response.write("{\"error\":1, \"reason\":\"Moving letter to an unknown place\"}"); + return false; + } + + for (const operation of operations) { + operation(); + } + + reply(message, response, cmdNumber, { + error: 0, + rack: ( + (cmd.from === "bag" && cmd.to === "rack") + ? rack + : undefined // eslint-disable-line no-undefined + ), + remainingLetters: game.bag.length + }); + + if (rack) { + game.pendingEvents.push({ + players: [{ + player: playerName, + rackCount: countTiles(rack) + }] + }); + } + break; + } + + case "setRack": { + if (cmd.rack.length > 7) { + reply(message, response, cmdNumber, { + error: 1, + rack: rack, + reason: "the new rack is not at the right size" + }); + return false; + } + + rack = game.getPlayerRack(playerName); + + const oldRackSorted = rack.filter((l) => l); + oldRackSorted.sort(); + + const newRackSorted = cmd.rack.filter((l) => l); + newRackSorted.sort(); + + for (let i = 0; i < 7; i++) { + if ((oldRackSorted[i] !== newRackSorted[i]) && (oldRackSorted[i] || newRackSorted[i])) { + reply(message, response, cmdNumber, { + error: 1, + rack: rack, + reason: "the new rack doesn't contain the same number of letters" + }); + return false; + } + } + + for (let i = 0; i < 7; i++) { + rack[i] = cmd.rack[i]; + } + + reply(message, response, cmdNumber, {error: 0}); + + break; + } + + case "resetGame": { + game.reset(); + reply(message, response, cmdNumber, {error: 0}); + break; + } + + case "msg": { + game.pendingEvents.push({ + msg: { + sender: playerName, + content: cmd.msg, + specialMsg: cmd.specialMsg + } + }); + + reply(message, response, cmdNumber, {error: 0}); + break; + } + + default: { + reply(message, response, cmdNumber, {error: 1, reason: "Unknown command"}); + return false; + } + } + + return true; +} + +function handleCommands(message, responseAndType) { + if (!message.cmds || !message.cmds.length) { + const {gameNumber, game} = joinGame(message.gameNumber); + + writeMessage(responseAndType, + JSON.stringify({ + playerName: message.playerName, + currentPlayer: game.currentPlayer, + gameNumber: gameNumber, + letterValues: game.letterValues, + rack: game.getPlayerRack(message.playerName), + board: game.board, + remainingLetters: game.bag.length, + version: VERSION + }) + ); + + game.addListeningPlayer(message.playerName, responseAndType); + return; + } + + let wsMessage = ""; + + const response = ( + responseAndType[1] === REQUEST_TYPE_WEBSOCKET + ? { + write: function (s) { + wsMessage += s; + }, + + end: function (s) { + if (s) { + wsMessage += s; + } + webSocketWrite(responseAndType[0], wsMessage); + } + } + : responseAndType[0] + ); + + response.write("["); + + for (let i = 0; i < message.cmds.length; i++) { + if (i) { + response.write(","); + } + + if (!handleCommand(i, message, response)) { + break; + } + } + + response.end("]"); + + if (games[message.gameNumber]) { + games[message.gameNumber].commit(); + } +} + +// Thx https://medium.com/hackernoon/implementing-a-websocket-server-with-node-js-d9b78ec5ffa8 + +function webSocketWrite(socket, data) { + /* eslint-disable capitalized-comments */ + + // Copy the data into a buffer + data = Buffer.from(data); + const byteLength = data.length; + + // Note: we're not supporting > 65535 byte payloads at this stage + const lengthByteCount = byteLength < 126 ? 0 : 2; + const payloadLength = lengthByteCount === 0 ? byteLength : 126; + const buffer = Buffer.alloc(2 + lengthByteCount + byteLength); + + // Write out the first byte, using opcode `1` to indicate that the message + // payload contains text data + buffer.writeUInt8(0b10000001, 0); + buffer.writeUInt8(payloadLength, 1); + + // Write the length of the payload to the second byte + + if (lengthByteCount === 2) { + buffer.writeUInt16BE(byteLength, 2); + } + + // Write the data to the data buffer + data.copy(buffer, 2 + lengthByteCount); + socket.write(buffer); +} + +function webSocketGetMessage(buffer) { + /* eslint-disable no-bitwise, capitalized-comments */ + + let dataAfter = ""; + let finalOffset = -1; + + if (buffer.length < 2) { + return ["", 0]; + } + + const firstByte = buffer.readUInt8(0); + + + const isFinalFrame = Boolean((firstByte >>> 7) & 0x1); + // const [reserved1, reserved2, reserved3] = [ + // Boolean((firstByte >>> 6) & 0x1), + // Boolean((firstByte >>> 5) & 0x1), + // Boolean((firstByte >>> 4) & 0x1) + // ]; + + const opCode = firstByte & 0xF; + + // We can return null to signify that this is a connection termination frame + if (opCode === 0x8) { + return null; + } + + const secondByte = buffer.readUInt8(1); + const isMasked = (secondByte >>> 7) & 0x1; + + // Keep track of our current position as we advance through the buffer + let currentOffset = 2; + let payloadLength = secondByte & 0x7F; + if (payloadLength > 125) { + if (payloadLength === 126) { + payloadLength = buffer.readUInt16BE(currentOffset); + currentOffset += 2; + } else { + // 127 + // If this has a value, the frame size is ridiculously huge! + // const leftPart = buffer.readUInt32BE(currentOffset); + // const rightPart = buffer.readUInt32BE(currentOffset += 4); + // Honestly, if the frame length requires 64 bits, you're probably doing it wrong. + // In Node.js you'll require the BigInt type, or a special library to handle this. + throw new Error("Large websocket payloads not currently implemented"); + } + } + + if (payloadLength + currentOffset > buffer.length) { + return ["", 0]; + } + + if (!isFinalFrame) { + const message = webSocketGetMessage(buffer.slice(payloadLength + currentOffset)); + if (message) { + if (message[1]) { + dataAfter = message[0]; + finalOffset = message[1]; + } else { + return ["", 0]; + } + } else if (message === null) { + // ??!? + return null; + } + } + + let maskingKey; + if (isMasked) { + maskingKey = buffer.readUInt32BE(currentOffset); + currentOffset += 4; + } + + // Allocate somewhere to store the final message data + const data = Buffer.alloc(payloadLength); + + // Only unmask the data if the masking bit was set to 1 + if (isMasked) { + // Loop through the source buffer one byte at a time, keeping track of which + // byte in the masking key to use in the next XOR calculation + for (let i = 0, j = 0; i < payloadLength; ++i, j = i % 4) { + // Extract the correct byte mask from the masking key + const shift = (j === 3) ? 0 : (3 - j) << 3; + const mask = (shift === 0 ? maskingKey : (maskingKey >>> shift)) & 0xFF; + + // Read a byte from the source buffer + const source = buffer.readUInt8(currentOffset++); + + // XOR the source byte and write the result to the data buffer + data.writeUInt8(mask ^ source, i); + } + } else { + // Not masked - we can just read the data as-is + buffer.copy(data, 0, currentOffset); + } + + return [ + opCode === 0x1 + ? (data.toString("utf8") + dataAfter) + : "", + finalOffset === -1 + ? currentOffset + : finalOffset + ]; +} + +function handleRequest(request, response) { + let post = ""; + const responseAndType = [response, REQUEST_TYPE_LONG_POLLING]; + let upgradedToWebSocket = false; + + response.on("error", function connectionError(e) { + console.error("An error occurred while trying to write on a socket", e); + stopKeepAlive(responseAndType); + }); + + // Thx http://stackoverflow.com/questions/4295782/how-do-you-extract-post-data-in-node-js + request.on("data", function (data) { + if (upgradedToWebSocket) { + return; + } + + post += data; + + // Too much POST data, kill the connection! + if (post.length > 1e6) { + request.connection.destroy(); + } + }); + + request.on("upgrade", function () { + upgradedToWebSocket = true; + }); + + request.on("error", function (error) { + console.error("Error while handling this request", request, error); + }); + + request.on("end", function () { + if (upgradedToWebSocket) { + return; + } + + if (!running) { + response.statusCode = 503; + response.statusMessage = "Server is stopping"; + response.end("Server is stopping"); + return; + } + + if (DEV_ENABLE_SERVING_FILES && !request.url.startsWith("/:")) { + if (request.url === "/") { + request.url = "/index.html"; + } + + debuglog("Serving " + request.url); + + fs.exists("public/" + request.url, function (exists) { + if (exists) { + fs.readFile("public/" + request.url, function(err, contents) { + if (err) { + response.statusCode = 500; + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.end(err); + } + + let mime = "application/xhtml+xml; charset=utf-8"; + const mimes = { + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml" + }; + + for (const i in mimes) { + if (Object.prototype.hasOwnProperty.call(mimes, i) && request.url.endsWith(i)) { + mime = mimes[i]; + if (i === ".js") { + contents = contents.toString().replace( + /(?^|\s|\()(?:const|let)(?\s)/gu, + "$1var$2" + ); + } + break; + } + } + + response.setHeader("Content-Type", mime); + response.end(contents); + }); + } else { + response.statusCode = 404; + response.end("404"); + } + }); + + return; + } + + const sseMatch = request.url.match(/\/(?::[^/]+\/)?sse\/(?[\s\S]+)$/u); + + if (sseMatch) { + response.setHeader("Content-Type", "text/event-stream"); + post = decodeURIComponent(sseMatch.groups.data); + responseAndType[1] = REQUEST_TYPE_SSE; + } else { + response.setHeader("Content-Type", "text/plain"); + response.setHeader("Transfer-Encoding", "chunked"); + } + + debuglog("REQ:", request.url, post, responseAndType[1]); + + try { + post = post && JSON.parse(post); + } catch (e) { + response.statusCode = 400; + response.setHeader("application/json"); + response.end('{"error":1, "reason": "Unable to parse your JSON data"}'); + return; + } + + handleCommands(post, responseAndType); + }); +} + +fs.readFile(GAMES_BACKUP, function (err, data) { + try { + if (err) { + console.error("WARNING: Could not restore previous backup of the games"); + } else { + const backup = JSON.parse(data); + + for (const gameNumber in backup) { + if (Object.prototype.hasOwnProperty.call(backup, gameNumber)) { + games[gameNumber] = Game.fromJSON(backup[gameNumber]); + } + } + } + } catch (e) { + console.error("WARNING: Could not restore previous backup of the games: file is broken:"); + console.error("WARNING: ", e); + } + + // See https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers + server.on("upgrade", function upgradeToWebsocket(request, socket) { + const responseAndType = [socket, REQUEST_TYPE_WEBSOCKET]; + + function closeSocket() { + responseAndType[0] = null; + } + + socket.on("error", function (e) { + console.error("Socket error", e.toString()); + closeSocket(); + }); + + socket.on("close", closeSocket); + + const webSocketMatch = request.url.match(/\/(?::[^/]+\/)?ws\/(?[\s\S]+)$/u); + + let decodedData; + try { + decodedData = decodeURIComponent(webSocketMatch.groups.data); + } catch (e) { + socket.end("HTTP/1.1 400 Bad Request"); + console.error("WS: FAILED - Could not decode the given data " + webSocketMatch.groups.data); + return; + } + + let message; + + if (webSocketMatch) { + try { + message = JSON.parse(decodedData); + } catch (e) { + socket.end("HTTP/1.1 400 Bad Request"); + console.error("WS: FAILED - Could not parse the given data " + decodedData); + return; + } + } else { + socket.end("HTTP/1.1 400 Bad Request"); + return; + } + + if (request.headers.upgrade.toLowerCase() !== "websocket") { + socket.end("HTTP/1.1 400 Bad Request"); + console.error("WS: FAILED - HTTP header 'Upgrade' is not 'websocket' " + decodedData); + return; + } + + socket.write( + "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" + + "Upgrade: WebSocket\r\n" + + "Connection: Upgrade\r\n" + ); + + const webSocketKey = request.headers["sec-websocket-key"]; + + if (webSocketKey) { + socket.write( + "Sec-WebSocket-Accept: " + ( + crypto + .createHash("sha1") + .update(webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "binary") + .digest("base64") + ) + "\r\n" + ); + } + + socket.write("\r\n"); + + debuglog("WS: established " + decodedData); + + let receivedBytes = null; + + socket.on("data", function webSocketData(buffer) { + if (receivedBytes) { + buffer = Buffer.concat([receivedBytes, buffer]); + receivedBytes = null; + } + + while (true) { + const wsMessage = webSocketGetMessage(buffer); + if (wsMessage && wsMessage[1]) { + if (wsMessage[0]) { + debuglog("WS message: " + wsMessage[0]); + try { + message.cmds = JSON.parse(wsMessage[0]); + } catch (e) { + writeMessage(responseAndType, '{"error":1, "reason": "Unable to parse your JSON data"}'); + return; + } + + handleCommands(message, responseAndType); + message.cmds = null; + } + } else if (wsMessage === null) { + if (responseAndType[0] && !socket.isDestroyed) { + responseAndType[0] = null; + socket.end(webSocketCloseBuffer); + } + + return; + } + + if (wsMessage[1] === buffer.length) { + break; + } + + if (wsMessage[1] === 0) { + receivedBytes = buffer; + return; + } + + buffer = buffer.slice(wsMessage[1]); + } + }); + + handleCommands(message, responseAndType); + }); + + server.on("error", function (error) { + console.error("An error happened in the HTTP server", error); + }); + + server.listen(port, function() { + console.log("Server listening on: http://localhost:%s", port); + }); +}); + + +process.once("SIGINT", function () { + console.log("SIGINT received..."); + stop(); +}); + +process.once("SIGTERM", function () { + console.log("SIGTERM received..."); + stop(); +}); + +process.on("uncaughtException", function (err) { + console.log(err); +}); diff --git a/trivabble-server.js b/trivabble-server.js deleted file mode 100644 index 51dacba..0000000 --- a/trivabble-server.js +++ /dev/null @@ -1,1202 +0,0 @@ -/* eslint-disable no-process-env */ -/** - * Copyright (C) 2016-2020 Raphaël Jakse - * - * @licstart - * This file is part of Trivabble. - * - * Trivabble is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License (GNU AGPL) - * as published by the Free Software Foundation, either version 3 of - * the License, or (at your option) any later version. - * - * Trivabble is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Trivabble. If not, see . - * @licend - * - * @source: https://trivabble.1s.fr/ - * @source: https://gitlab.com/raphj/trivabble/ - */ - -/*eslint strict: [2, "global"]*/ - -"use strict"; - -const port = parseInt(process.env.TRIVABBLE_PORT || "3000"); -const SAVE_TIMEOUT = 5000; -const KEEP_ALIVE = 30000; -const GAMES_BACKUP = process.env.TRIVABBLE_GAMES_BACKUP || "games.backup.json"; - -const VERSION = 202004281800; - -function envTrue(name) { - return (process.env[name] || "").toLowerCase() === "true"; -} - -const DEV_ENABLE_SERVING_FILES = envTrue("DEV_ENABLE_SERVING_FILES"); -const DEBUG_LOG = DEV_ENABLE_SERVING_FILES || envTrue("DEBUG_LOG"); - -if (DEV_ENABLE_SERVING_FILES) { - console.log("DEV_ENABLE_SERVING_FILES: Serving files in the current directory. Please never do this on a production server, this is for development purposes only."); -} - -const debuglog = DEBUG_LOG ? console.log.bind(console) : () => null; - -const http = require("http"); -const fs = require("fs"); -const crypto = require("crypto"); - -const REQUEST_TYPE_LONG_POLLING = 1; -const REQUEST_TYPE_SSE = 2; -const REQUEST_TYPE_WEBSOCKET = 3; - -const frBag = [ - " ", " ", // Jokers - "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", - "A", "A", "A", "A", "A", "A", "A", "A", "A", - "I", "I", "I", "I", "I", "I", "I", "I", - "N", "N", "N", "N", "N", "N", - "O", "O", "O", "O", "O", "O", - "R", "R", "R", "R", "R", "R", - "S", "S", "S", "S", "S", "S", - "T", "T", "T", "T", "T", "T", - "U", "U", "U", "U", "U", "U", - "L", "L", "L", "L", "L", - "D", "D", "D", - "M", "M", "M", - "G", "G", - "B", "B", - "C", "C", - "P", "P", - "F", "F", - "H", "H", - "V", "V", - "J", - "Q", - "K", - "W", - "X", - "Y", - "Z" -]; - -/* eslint-disable quote-props */ - -const frValues = { - " ": 0, - "E": 1, - "A": 1, - "I": 1, - "N": 1, - "O": 1, - "R": 1, - "S": 1, - "T": 1, - "U": 1, - "L": 1, - "D": 2, - "M": 2, - "G": 2, - "B": 3, - "C": 3, - "P": 3, - "F": 4, - "H": 4, - "V": 4, - "J": 8, - "Q": 8, - "K": 10, - "W": 10, - "X": 10, - "Y": 10, - "Z": 10 -}; - -/* eslint-enable quote-props */ - -const games = {}; - -let saveTimeout = null; -let running = true; -const server = http.createServer(handleRequest); -server.setTimeout(0); // The default in node 13 is 0. Earlier versions have 120. - -function saveGames(callback) { - fs.writeFile(GAMES_BACKUP, JSON.stringify(games), function (err) { - if (err) { - console.error("ERROR: Cannot save games!"); - } - - if (typeof callback === "function") { - return callback(err); - } - }); - - saveTimeout = null; -} - - -function shuffleInPlace(a) { - // https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array - - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const x = a[i]; - a[i] = a[j]; - a[j] = x; - } -} - -function Game() { - this.letterValues = frValues; - this.init(); - this.listeningPlayers = []; - this.pendingEvents = []; -} - -function writeMessage(responseAndType, data, terminate) { - if (!responseAndType[0]) { - return; - } - - if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET) { - webSocketWrite(responseAndType[0], data); - } else { - responseAndType[0][terminate ? "end" : "write"]( - responseAndType[1] === REQUEST_TYPE_SSE - ? "data:" + data + "\n\n" - : data.length + data - ); - } - - if (terminate) { - stopKeepAlive(responseAndType); - if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET && responseAndType[0] && !responseAndType[0].isDestroyed) { - responseAndType[0].end(webSocketCloseBuffer); - responseAndType[0] = null; - } - } else { - keepAlive(responseAndType); - } -} - -function stop() { - console.log("Saving games a first time"); - if (saveTimeout) { - clearTimeout(saveTimeout); - saveTimeout = null; - } - - saveGames(function () { - running = false; - console.log("Closing connections and saving the game state..."); - - let listeningPlayerCount = 0; - let gamesCount = 0; - - for (const gameID of Object.keys(games)) { - const game = games[gameID]; - for (const listeningPlayer of game.listeningPlayers) { - writeMessage(listeningPlayer, '{"stopping": 2000}', true); - } - - if (game.listeningPlayers.length) { - listeningPlayerCount += game.listeningPlayers.length; - gamesCount++; - } - } - - console.log( - "Stopped", gamesCount, "game" + (gamesCount === 1 ? "" : "s"), - "and", listeningPlayerCount, "player connection" + (listeningPlayerCount === 1 ? "" : "s") + - "." - ); - - server.close(saveGames); - }); -} - -const webSocketPingBuffer = Buffer.from([ - 0b10001001, 0b00000000 -]); - -const webSocketCloseBuffer = Buffer.from([ - 0b10001000, 0b00000000 -]); - - -function stopKeepAlive(responseAndType) { - if (responseAndType.keepAliveTimeout) { - clearTimeout(responseAndType.keepAliveTimeout); - responseAndType.keepAliveTimeout = 0; - } -} - -function keepAlive(responseAndType) { - stopKeepAlive(responseAndType); - responseAndType.keepAliveTimeout = setTimeout(function keepAliveTimeout() { - responseAndType[0].write( - responseAndType[1] === REQUEST_TYPE_WEBSOCKET - ? webSocketPingBuffer - : ( - responseAndType[1] === REQUEST_TYPE_SSE - ? ":\n\n" - : "2[]" - ) - ); - }, KEEP_ALIVE); -} - -function newBoard() { - const res = new Array(15 * 15); - - for (let i = 0; i < 15 * 15; i++) { - res[i] = ""; - } - - return res; -} - -Game.prototype.init = function () { - this.board = newBoard(); - this.bag = frBag.slice(); - this.racks = {}; - this.scores = {}; - this.lastUpdated = new Date(); - this.currentPlayer = ""; - - shuffleInPlace(this.bag); -}; - -Game.prototype.toJSON = function () { - return { - board: this.board, - bag: this.bag, - racks: this.racks, - scores: this.scores, - lastUpdated: this.lastUpdated.toISOString(), - currentPlayer: this.currentPlayer - }; -}; - -Game.fromJSON = function (obj) { - const game = new Game(); - game.board = obj.board || newBoard(); - game.bag = obj.bag || frBag.slice(); - game.racks = obj.racks || {}; - game.scores = obj.scores || {}; - game.lastUpdated = obj.lastUpdated ? new Date(obj.lastUpdated) : new Date(); - game.currentPlayer = obj.currentPlayer || ""; - return game; -}; - -Game.prototype.getPlayerRack = function (player) { - const playerID = "#" + player; - return (this.racks[playerID] || (this.racks[playerID] = [])); -}; - -Game.prototype.getPlayerScore = function (player) { - const playerID = "#" + player; - return (this.scores[playerID] || (this.scores[playerID] = 0)); -}; - -Game.prototype.setPlayerScore = function (player, score) { - const playerID = "#" + player; - - if (!Object.prototype.hasOwnProperty.call(this.racks, playerID) || typeof score !== "number") { - return; - } - - this.scores[playerID] = score; - - this.pendingEvents.push({ - players: [{ - player: player, - score: score - }] - }); -}; - -Game.prototype.setCurrentPlayer = function (player) { - this.currentPlayer = player; - this.pendingEvents.push({currentPlayer: player}); -}; - -Game.prototype.playerJoined = function (playerName) { - if (playerName) { - this.getPlayerRack(playerName); // Create the player's rack - } - - const players = []; - - for (let player in this.racks) { - if (Object.prototype.hasOwnProperty.call(this.racks, player)) { - player = player.slice(1); // '#' - players.push({ - player: player, - score: this.getPlayerScore(player), - rackCount: countTiles(this.getPlayerRack(player)) - }); - } - } - - this.pendingEvents.push({players: players}); -}; - -Game.prototype.addListeningPlayer = function (playerName, responseAndType) { - const that = this; - - that.listeningPlayers.push(responseAndType); - keepAlive(responseAndType); - - let closed = false; - - function close () { - if (closed) { - return; - } - - closed = true; - stopKeepAlive(responseAndType); - responseAndType[0] = null; - - const index = that.listeningPlayers.indexOf(responseAndType); - if (index !== -1) { - that.listeningPlayers[index] = that.listeningPlayers[that.listeningPlayers.length - 1]; - that.listeningPlayers.pop(); - } - } - - responseAndType[0].on("error", close); - responseAndType[0].on("close", close); - responseAndType[0].on("finish", close); - responseAndType[0].on("prefinish", close); - - this.playerJoined(playerName); - this.commit(); -}; - -Game.prototype.commit = function () { - const msg = JSON.stringify(this.pendingEvents); - this.pendingEvents = []; - - for (let i = 0; i < this.listeningPlayers.length; i++) { - while (i < this.listeningPlayers.length && !this.listeningPlayers[i]) { - this.listeningPlayers[i] = this.listeningPlayers[this.listeningPlayers.length - 1]; - this.listeningPlayers.pop(); - } - - if (this.listeningPlayers[i]) { - writeMessage(this.listeningPlayers[i], msg); - } - } - - if (saveTimeout === null) { - saveTimeout = setTimeout(saveGames, SAVE_TIMEOUT); - } -}; - -Game.prototype.bagPopLetter = function (player) { - if (this.bag.length) { - const letter = this.bag.pop(); - this.pendingEvents.push({ - player: player, - action: "popBag", - remainingLetters: this.bag.length - }); - - return letter; - } - - return ""; -}; - -Game.prototype.getCell = function (index) { - return this.board[index]; -}; - -Game.prototype.setCell = function (index, letter, player) { - this.board[index] = letter; - this.pendingEvents.push({ - player: player, - action: "setCell", - indexTo: index, - letter: letter - }); -}; - -Game.prototype.bagPushLetter = function (letter, player) { - if (letter) { - this.bag.push(letter); - shuffleInPlace(this.bag); - - this.pendingEvents.push({ - player: player, - action: "pushBag", - remainingLetters: this.bag.length - }); - } -}; - -Game.prototype.reset = function (player) { - this.init(); - this.pendingEvents.push({ - player: player, - action: "reset", - board: this.board, - remainingLetters: this.bag.length, - rack: [] - }); - - this.playerJoined(); -}; - -function newGameId() { - let number; - - let k = 10000; - let retries = 0; - - do { - number = Math.floor(Math.random() * k).toString(); - - if (retries > 10) { - retries = 0; - k *= 10; - } else { - retries++; - } - } while (games[number]); - - return number.toString(); -} - -function countTiles(rack) { - let count = 0; - - for (let i = 0; i < rack.length; i++) { - if (rack[i]) { - count++; - } - } - - return count; -} - -function joinGame(gameNumber) { - if (!gameNumber) { - gameNumber = newGameId(); - } - - const game = games[gameNumber] || (games[gameNumber] = new Game()); - - return {gameNumber, game}; -} - -function reply(message, response, cmdNumber, r) { - response.write( - JSON.stringify({...r, id: message.id || message.cmds[cmdNumber].id}) - ); -} - -function handleCommand(cmdNumber, message, response) { - const {gameNumber, game} = joinGame(message.gameNumber); - - game.lastUpdated = new Date(); - - const playerName = message.playerName; - - let rack = null; - const cmd = message.cmds[cmdNumber]; - - switch (cmd.cmd) { - case "sync": // DEPRECATED. Here for old clients. - case "joinGame": { - reply(message, response, cmdNumber, { - error: 0, - gameNumber: gameNumber, - playerName: playerName, - currentPlayer: game.currentPlayer, - rack: game.getPlayerRack(playerName), - board: game.board, - remainingLetters: game.bag.length, - letterValues: frValues, - version: VERSION - }); - break; - } - - case "hello": { - game.playerJoined(playerName); - reply(message, response, cmdNumber, {error: 0, version: VERSION}); - break; - } - - case "score": { - game.setPlayerScore(cmd.player, cmd.score); - reply(message, response, cmdNumber, {error: 0}); - break; - } - - case "currentPlayer": { - game.setCurrentPlayer(cmd.player); - reply(message, response, cmdNumber, {error: 0}); - break; - } - - case "moveLetter": { - let letter = ""; - - // This case can fail in various ways. Instead altering the state - // of the game immediately, we store the operations to run them - // at the very end, if nothing failed. - const operations = []; - - switch (cmd.from) { - case "rack": - rack = game.getPlayerRack(playerName); - - if (cmd.indexFrom > 6 || cmd.indexFrom < 0) { - reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"}); - return false; - } - - letter = rack[cmd.indexFrom]; - operations.push(() => { - rack[cmd.indexFrom] = ""; - }); - break; - - case "board": - if (cmd.indexFrom < 0 || cmd.indexFrom >= 15 * 15) { - reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"}); - return false; - } - - letter = game.getCell(cmd.indexFrom); - operations.push(game.setCell.bind(game, cmd.indexFrom, "", playerName)); - break; - - case "bag": - if (!game.bag.length) { - reply(message, response, cmdNumber, {error: 1, reason: "Empty bag"}); - return false; - } - - operations.push(() => { - letter = game.bagPopLetter(playerName); - }); - break; - - default: - reply(message, response, cmdNumber, {error: 1, reason: "Moving letter from an unknown place"}); - return false; - } - - switch (cmd.to) { - case "rack": - if (cmd.indexTo < 0 || cmd.indexTo > 6) { - reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"}); - return false; - } - - rack = rack || game.getPlayerRack(playerName); - - if (rack[cmd.indexTo]) { - reply(message, response, cmdNumber, {error: 1, reason: "Moving a tile to a non-empty location"}); - return false; - } - - operations.push(() => { - rack[cmd.indexTo] = letter; - }); - break; - - case "board": - if (cmd.indexTo < 0 || cmd.indexTo >= 15 * 15) { - reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"}); - return false; - } - - operations.push(game.setCell.bind(game, cmd.indexTo, letter, playerName)); - break; - - case "bag": - operations.push(game.bagPushLetter.bind(game, letter, playerName)); - break; - - default: - response.write("{\"error\":1, \"reason\":\"Moving letter to an unknown place\"}"); - return false; - } - - for (const operation of operations) { - operation(); - } - - reply(message, response, cmdNumber, { - error: 0, - rack: ( - (cmd.from === "bag" && cmd.to === "rack") - ? rack - : undefined // eslint-disable-line no-undefined - ), - remainingLetters: game.bag.length - }); - - if (rack) { - game.pendingEvents.push({ - players: [{ - player: playerName, - rackCount: countTiles(rack) - }] - }); - } - break; - } - - case "setRack": { - if (cmd.rack.length > 7) { - reply(message, response, cmdNumber, { - error: 1, - rack: rack, - reason: "the new rack is not at the right size" - }); - return false; - } - - rack = game.getPlayerRack(playerName); - - const oldRackSorted = rack.filter((l) => l); - oldRackSorted.sort(); - - const newRackSorted = cmd.rack.filter((l) => l); - newRackSorted.sort(); - - for (let i = 0; i < 7; i++) { - if ((oldRackSorted[i] !== newRackSorted[i]) && (oldRackSorted[i] || newRackSorted[i])) { - reply(message, response, cmdNumber, { - error: 1, - rack: rack, - reason: "the new rack doesn't contain the same number of letters" - }); - return false; - } - } - - for (let i = 0; i < 7; i++) { - rack[i] = cmd.rack[i]; - } - - reply(message, response, cmdNumber, {error: 0}); - - break; - } - - case "resetGame": { - game.reset(); - reply(message, response, cmdNumber, {error: 0}); - break; - } - - case "msg": { - game.pendingEvents.push({ - msg: { - sender: playerName, - content: cmd.msg, - specialMsg: cmd.specialMsg - } - }); - - reply(message, response, cmdNumber, {error: 0}); - break; - } - - default: { - reply(message, response, cmdNumber, {error: 1, reason: "Unknown command"}); - return false; - } - } - - return true; -} - -function handleCommands(message, responseAndType) { - if (!message.cmds || !message.cmds.length) { - const {gameNumber, game} = joinGame(message.gameNumber); - - writeMessage(responseAndType, - JSON.stringify({ - playerName: message.playerName, - currentPlayer: game.currentPlayer, - gameNumber: gameNumber, - letterValues: game.letterValues, - rack: game.getPlayerRack(message.playerName), - board: game.board, - remainingLetters: game.bag.length, - version: VERSION - }) - ); - - game.addListeningPlayer(message.playerName, responseAndType); - return; - } - - let wsMessage = ""; - - const response = ( - responseAndType[1] === REQUEST_TYPE_WEBSOCKET - ? { - write: function (s) { - wsMessage += s; - }, - - end: function (s) { - if (s) { - wsMessage += s; - } - webSocketWrite(responseAndType[0], wsMessage); - } - } - : responseAndType[0] - ); - - response.write("["); - - for (let i = 0; i < message.cmds.length; i++) { - if (i) { - response.write(","); - } - - if (!handleCommand(i, message, response)) { - break; - } - } - - response.end("]"); - - if (games[message.gameNumber]) { - games[message.gameNumber].commit(); - } -} - -// Thx https://medium.com/hackernoon/implementing-a-websocket-server-with-node-js-d9b78ec5ffa8 - -function webSocketWrite(socket, data) { - /* eslint-disable capitalized-comments */ - - // Copy the data into a buffer - data = Buffer.from(data); - const byteLength = data.length; - - // Note: we're not supporting > 65535 byte payloads at this stage - const lengthByteCount = byteLength < 126 ? 0 : 2; - const payloadLength = lengthByteCount === 0 ? byteLength : 126; - const buffer = Buffer.alloc(2 + lengthByteCount + byteLength); - - // Write out the first byte, using opcode `1` to indicate that the message - // payload contains text data - buffer.writeUInt8(0b10000001, 0); - buffer.writeUInt8(payloadLength, 1); - - // Write the length of the payload to the second byte - - if (lengthByteCount === 2) { - buffer.writeUInt16BE(byteLength, 2); - } - - // Write the data to the data buffer - data.copy(buffer, 2 + lengthByteCount); - socket.write(buffer); -} - -function webSocketGetMessage(buffer) { - /* eslint-disable no-bitwise, capitalized-comments */ - - let dataAfter = ""; - let finalOffset = -1; - - if (buffer.length < 2) { - return ["", 0]; - } - - const firstByte = buffer.readUInt8(0); - - - const isFinalFrame = Boolean((firstByte >>> 7) & 0x1); - // const [reserved1, reserved2, reserved3] = [ - // Boolean((firstByte >>> 6) & 0x1), - // Boolean((firstByte >>> 5) & 0x1), - // Boolean((firstByte >>> 4) & 0x1) - // ]; - - const opCode = firstByte & 0xF; - - // We can return null to signify that this is a connection termination frame - if (opCode === 0x8) { - return null; - } - - const secondByte = buffer.readUInt8(1); - const isMasked = (secondByte >>> 7) & 0x1; - - // Keep track of our current position as we advance through the buffer - let currentOffset = 2; - let payloadLength = secondByte & 0x7F; - if (payloadLength > 125) { - if (payloadLength === 126) { - payloadLength = buffer.readUInt16BE(currentOffset); - currentOffset += 2; - } else { - // 127 - // If this has a value, the frame size is ridiculously huge! - // const leftPart = buffer.readUInt32BE(currentOffset); - // const rightPart = buffer.readUInt32BE(currentOffset += 4); - // Honestly, if the frame length requires 64 bits, you're probably doing it wrong. - // In Node.js you'll require the BigInt type, or a special library to handle this. - throw new Error("Large websocket payloads not currently implemented"); - } - } - - if (payloadLength + currentOffset > buffer.length) { - return ["", 0]; - } - - if (!isFinalFrame) { - const message = webSocketGetMessage(buffer.slice(payloadLength + currentOffset)); - if (message) { - if (message[1]) { - dataAfter = message[0]; - finalOffset = message[1]; - } else { - return ["", 0]; - } - } else if (message === null) { - // ??!? - return null; - } - } - - let maskingKey; - if (isMasked) { - maskingKey = buffer.readUInt32BE(currentOffset); - currentOffset += 4; - } - - // Allocate somewhere to store the final message data - const data = Buffer.alloc(payloadLength); - - // Only unmask the data if the masking bit was set to 1 - if (isMasked) { - // Loop through the source buffer one byte at a time, keeping track of which - // byte in the masking key to use in the next XOR calculation - for (let i = 0, j = 0; i < payloadLength; ++i, j = i % 4) { - // Extract the correct byte mask from the masking key - const shift = (j === 3) ? 0 : (3 - j) << 3; - const mask = (shift === 0 ? maskingKey : (maskingKey >>> shift)) & 0xFF; - - // Read a byte from the source buffer - const source = buffer.readUInt8(currentOffset++); - - // XOR the source byte and write the result to the data buffer - data.writeUInt8(mask ^ source, i); - } - } else { - // Not masked - we can just read the data as-is - buffer.copy(data, 0, currentOffset); - } - - return [ - opCode === 0x1 - ? (data.toString("utf8") + dataAfter) - : "", - finalOffset === -1 - ? currentOffset - : finalOffset - ]; -} - -function handleRequest(request, response) { - let post = ""; - const responseAndType = [response, REQUEST_TYPE_LONG_POLLING]; - let upgradedToWebSocket = false; - - response.on("error", function connectionError(e) { - console.error("An error occurred while trying to write on a socket", e); - stopKeepAlive(responseAndType); - }); - - // Thx http://stackoverflow.com/questions/4295782/how-do-you-extract-post-data-in-node-js - request.on("data", function (data) { - if (upgradedToWebSocket) { - return; - } - - post += data; - - // Too much POST data, kill the connection! - if (post.length > 1e6) { - request.connection.destroy(); - } - }); - - request.on("upgrade", function () { - upgradedToWebSocket = true; - }); - - request.on("error", function (error) { - console.error("Error while handling this request", request, error); - }); - - request.on("end", function () { - if (upgradedToWebSocket) { - return; - } - - if (!running) { - response.statusCode = 503; - response.statusMessage = "Server is stopping"; - response.end("Server is stopping"); - return; - } - - if (DEV_ENABLE_SERVING_FILES && !request.url.startsWith("/:")) { - if (request.url === "/") { - request.url = "/index.html"; - } - - debuglog("Serving " + request.url); - - fs.exists("public/" + request.url, function (exists) { - if (exists) { - fs.readFile("public/" + request.url, function(err, contents) { - if (err) { - response.statusCode = 500; - response.setHeader("Content-Type", "text/plain; charset=utf-8"); - response.end(err); - } - - let mime = "application/xhtml+xml; charset=utf-8"; - const mimes = { - ".mp3": "audio/mpeg", - ".ogg": "audio/ogg", - ".js": "application/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".svg": "image/svg+xml" - }; - - for (const i in mimes) { - if (Object.prototype.hasOwnProperty.call(mimes, i) && request.url.endsWith(i)) { - mime = mimes[i]; - if (i === ".js") { - contents = contents.toString().replace( - /(?^|\s|\()(?:const|let)(?\s)/gu, - "$1var$2" - ); - } - break; - } - } - - response.setHeader("Content-Type", mime); - response.end(contents); - }); - } else { - response.statusCode = 404; - response.end("404"); - } - }); - - return; - } - - const sseMatch = request.url.match(/\/(?::[^/]+\/)?sse\/(?[\s\S]+)$/u); - - if (sseMatch) { - response.setHeader("Content-Type", "text/event-stream"); - post = decodeURIComponent(sseMatch.groups.data); - responseAndType[1] = REQUEST_TYPE_SSE; - } else { - response.setHeader("Content-Type", "text/plain"); - response.setHeader("Transfer-Encoding", "chunked"); - } - - debuglog("REQ:", request.url, post, responseAndType[1]); - - try { - post = post && JSON.parse(post); - } catch (e) { - response.statusCode = 400; - response.setHeader("application/json"); - response.end('{"error":1, "reason": "Unable to parse your JSON data"}'); - return; - } - - handleCommands(post, responseAndType); - }); -} - -fs.readFile(GAMES_BACKUP, function (err, data) { - try { - if (err) { - console.error("WARNING: Could not restore previous backup of the games"); - } else { - const backup = JSON.parse(data); - - for (const gameNumber in backup) { - if (Object.prototype.hasOwnProperty.call(backup, gameNumber)) { - games[gameNumber] = Game.fromJSON(backup[gameNumber]); - } - } - } - } catch (e) { - console.error("WARNING: Could not restore previous backup of the games: file is broken:"); - console.error("WARNING: ", e); - } - - // See https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers - server.on("upgrade", function upgradeToWebsocket(request, socket) { - const responseAndType = [socket, REQUEST_TYPE_WEBSOCKET]; - - function closeSocket() { - responseAndType[0] = null; - } - - socket.on("error", function (e) { - console.error("Socket error", e.toString()); - closeSocket(); - }); - - socket.on("close", closeSocket); - - const webSocketMatch = request.url.match(/\/(?::[^/]+\/)?ws\/(?[\s\S]+)$/u); - - let decodedData; - try { - decodedData = decodeURIComponent(webSocketMatch.groups.data); - } catch (e) { - socket.end("HTTP/1.1 400 Bad Request"); - console.error("WS: FAILED - Could not decode the given data " + webSocketMatch.groups.data); - return; - } - - let message; - - if (webSocketMatch) { - try { - message = JSON.parse(decodedData); - } catch (e) { - socket.end("HTTP/1.1 400 Bad Request"); - console.error("WS: FAILED - Could not parse the given data " + decodedData); - return; - } - } else { - socket.end("HTTP/1.1 400 Bad Request"); - return; - } - - if (request.headers.upgrade.toLowerCase() !== "websocket") { - socket.end("HTTP/1.1 400 Bad Request"); - console.error("WS: FAILED - HTTP header 'Upgrade' is not 'websocket' " + decodedData); - return; - } - - socket.write( - "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" + - "Upgrade: WebSocket\r\n" + - "Connection: Upgrade\r\n" - ); - - const webSocketKey = request.headers["sec-websocket-key"]; - - if (webSocketKey) { - socket.write( - "Sec-WebSocket-Accept: " + ( - crypto - .createHash("sha1") - .update(webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "binary") - .digest("base64") - ) + "\r\n" - ); - } - - socket.write("\r\n"); - - debuglog("WS: established " + decodedData); - - let receivedBytes = null; - - socket.on("data", function webSocketData(buffer) { - if (receivedBytes) { - buffer = Buffer.concat([receivedBytes, buffer]); - receivedBytes = null; - } - - while (true) { - const wsMessage = webSocketGetMessage(buffer); - if (wsMessage && wsMessage[1]) { - if (wsMessage[0]) { - debuglog("WS message: " + wsMessage[0]); - try { - message.cmds = JSON.parse(wsMessage[0]); - } catch (e) { - writeMessage(responseAndType, '{"error":1, "reason": "Unable to parse your JSON data"}'); - return; - } - - handleCommands(message, responseAndType); - message.cmds = null; - } - } else if (wsMessage === null) { - if (responseAndType[0] && !socket.isDestroyed) { - responseAndType[0] = null; - socket.end(webSocketCloseBuffer); - } - - return; - } - - if (wsMessage[1] === buffer.length) { - break; - } - - if (wsMessage[1] === 0) { - receivedBytes = buffer; - return; - } - - buffer = buffer.slice(wsMessage[1]); - } - }); - - handleCommands(message, responseAndType); - }); - - server.on("error", function (error) { - console.error("An error happened in the HTTP server", error); - }); - - server.listen(port, function() { - console.log("Server listening on: http://localhost:%s", port); - }); -}); - - -process.once("SIGINT", function () { - console.log("SIGINT received..."); - stop(); -}); - -process.once("SIGTERM", function () { - console.log("SIGTERM received..."); - stop(); -}); - -process.on("uncaughtException", function (err) { - console.log(err); -});