Exit Full View

Games Cupboard / gamescupboard-server / src / main / kotlin / uk / co / nickthecoder / gamescupboard / server / GamesCupboardServer.kt

package uk.co.nickthecoder.gamescupboard.server

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.engine.*
import io.ktor.server.html.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.util.*
import kotlinx.html.a
import kotlinx.html.h2
import uk.co.nickthecoder.gamescupboard.server.games.*
import java.io.File
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import kotlin.system.exitProcess

object GamesCupboardServer {
    private const val USAGE = """Usage

    gamescupboard-server [--dev] [--port=PORT] [--www=STATIC_FILES_PATH] [--pass=PASSWORD_FILE]
        --dev     : Creates "dummy" games at startup. Handy for testing.
        --port    : The web server's port. (Default = 8088)
        --www     : The file path of the "www" directory in gamescupboard-client subproject.
        --pass    : The file path containing "admin" user names, and hashed passwords.
"""

    private const val DEFAULT_PORT = 8088
    var wwwPath: String = File("../gamescupboard-client/build/www").canonicalPath

    private val gamesByGameId = ConcurrentHashMap<Int, Game>()
    private val gamesByVariation = ConcurrentHashMap<GameVariation, MutableList<Game>>()

    fun addGame(game: Game) {
        gamesByGameId[game.id] = game
        val list = gamesByVariation[game.baseVariation]
        if (list == null) {
            gamesByVariation[game.baseVariation] = mutableListOf(game)
        } else {
            list.add(game)
        }
    }

    fun activeCount(gameType: GameType) = gamesByGameId.values.count { it.gameType === gameType }

    fun removeGame(game: Game) {
        gamesByGameId.remove(game.id)
        gamesByVariation.remove(game.gameVariation)
    }

    fun findGameById(gameId: Int) = gamesByGameId[gameId]
    fun findGamesByVariation(gameVariation: GameVariation): List<Game> =
        gamesByVariation[gameVariation] ?: emptyList()

    fun allGames(): Collection<Game> = gamesByGameId.values

    @JvmStatic
    fun main(vararg args: String) {
        var port = DEFAULT_PORT
        var isDev = false
        var passwordFile: File? = null

        for (arg in args) {
            if (arg == "--dev") {
                isDev = true
                continue
            }
            if (arg.startsWith("--port=")) {
                val p = arg.substring(7).toIntOrNull()
                if (p != null) {
                    port = p
                    continue
                }
            }
            if (arg.startsWith("--www=")) {
                wwwPath = File(arg.substring(6)).absolutePath
                continue
            }
            if (arg.startsWith("--pass=")) {
                passwordFile = File(arg.substring(7))
                continue
            }
            println("Unexpected parameter '$arg'")
            println(USAGE)
            exitProcess(-1)
        }

        if (isDev) {
            println("Server on localhost port $port ")
            // "dummy" games.
            // Handy for development, as we don't need to go to the website to create a game!.
            addGame(Game(cards, cards.variations[0], "Test Cards", 2))
            addGame(Game(chess, chess.variations[0], "Test Chess", 2))
            addGame(Game(hex, hex.variations[0], "Test Hex", 2))
            addGame(Game(super7, super7.variations[0], "Test Super7", 2))
            addGame(Game(chess, chess.variations[3], "Test Draughts", 2))
            addGame(Game(connect4, connect4.variations[0], "Test Connect4", 2))
            addGame(Game(qwirkle, qwirkle.variations[0], "Test RummyCross", 4))
            addGame(Game(bananagrams, bananagrams.variations[0], "Test Banagrams", 4))
            addGame(Game(connect4, connect4.variations[1], "Test Gomoku", 2))
            addGame(Game(scrabble, scrabble.variations[0], "Test Scrabble", 2))
            println("Started some game instances for testing purposes")
        }

        // At the moment, I'm storing admin usernames and passwords in a text file.
        // Later I may move to an sqLite database.
        // Especially if/when I allow users to create accounts!
        val authMap = readPasswordFile(passwordFile)
        val hashingAlgorithm = getDigestFunction("SHA-256") { "ntc${it.length}" }

        println("Starting GamesCupboard server on port $port")
        embeddedServer(Netty, port = port) {

            install(StatusPages) {
                exception<Throwable> { call, cause ->
                    println("Exception : $cause")
                    call.respondHtmlTemplate(LobbyTemplate()) {
                        pageTitle { +"Oops" }
                        pageHeading { +"Oops, something went wrong" }

                        content {
                            h2 { +"${cause.message}" }
                            a(href = "/") { +"Back to the Lobby" }
                        }
                    }
                }
                status(HttpStatusCode.NotFound) { call, _ ->
                    call.respondHtmlTemplate(LobbyTemplate()) {
                        pageTitle { +"Not Found" }
                        pageHeading { +"Page Not Found" }

                        content {
                            a(href = "/") { +"Back to the Lobby" }
                        }
                    }
                }
            }

            install(Authentication) {
                basic("auth-basic") {
                    realm = "gamesCupboard"
                    validate { credentials ->
                        if (authMap[credentials.name] == hashingAlgorithm.invoke(credentials.password).toHex()) {
                            UserIdPrincipal(credentials.name)
                        } else {
                            null
                        }
                    }
                }
            }

            install(WebSockets) {
                pingPeriod = Duration.ofSeconds(15)
                timeout = Duration.ofSeconds(15)
                maxFrameSize = Long.MAX_VALUE
                masking = false
            }

            routing {
                Lobby.route(this)
                Play.route(this)
            }

        }.start(wait = true)
    }

}

/**
 * Reads a file where each line consists of a username space password_hash.
 * The password_hash should be the users password hashed using SHA256, with a salt
 * of : "ntc${password.length}"
 *
 * So the hash of "password" is 4e00df21bd781f1f9876e53fefae13694f088bd2b4541e079a0bfc26326fec7b
 *
 * To generate a hash from the command line :
 *
 *      echo -n ntcMY_PASSWORD_LENGTH'MY_PASSWORD' | sha256sum
 */
private fun readPasswordFile(file: File?): Map<String, String> {
    if (file == null) return emptyMap()

    val map = mutableMapOf<String, String>()
    for (line in file.readLines()) {
        if (line.startsWith("#") || line.startsWith("//")) {
            continue
        }
        val trimmed = line.trim()
        val space = trimmed.indexOf(' ')
        if (space > 0) {
            map[line.substring(0, space).trim()] = line.substring(space).trim()
        }
    }
    return map
}

fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }