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) }