Registration, Login, Resume, Table Creation, Dark Style

This commit is contained in:
Christoph Hagen 2021-11-28 15:53:47 +01:00
parent b87dce55a8
commit 3a1ef01a54
8 changed files with 940 additions and 189 deletions

View File

@ -1,15 +1,270 @@
// The web socket to connect to the server
var socket = null;
function closeSocketIfNeeded() {
if (socket) {
socket.close()
socket = null
}
}
function hideLoginWindow() { function hideLoginWindow() {
document.getElementById("signup-window").style.display = "none" document.getElementById("signup-window").style.display = "none"
} }
function showLoginWindow() {
document.getElementById("signup-window").style.display = "table"
}
function setPlayerName(name) {
document.getElementById("player-name").innerHTML = name
}
function getPlayerName() {
return document.getElementById("player-name").innerHTML
}
function getLoginName() {
return document.getElementById("user-name").value
}
function clearLoginName() {
document.getElementById("user-name").value = ""
}
function getLoginPassword() {
return document.getElementById("user-pwd").value
}
function clearLoginPassword() {
document.getElementById("user-pwd").value = ""
}
function getSessionToken() {
return localStorage.getItem('token')
}
function setSessionToken(token) {
localStorage.setItem('token', token)
}
function deleteSessionToken() {
localStorage.removeItem('token')
}
function setLoginError(text) {
document.getElementById("login-error").innerHTML = text
}
function getTableName() {
return document.getElementById("table-name-field").value
}
function clearTableName() {
return document.getElementById("table-name-field").value = ""
}
function getTableVisibility() {
return document.getElementById("table-public-checkbox").checked
}
function setTableListContent(content) {
document.getElementById("table-list").innerHTML = content
}
function showBlankLoginScreen(text) {
closeSocketIfNeeded()
clearLoginPassword()
clearLoginName()
deleteSessionToken()
showLoginWindow()
setLoginError(text)
}
async function registerUser() { async function registerUser() {
let username = document.getElementById("user-name").value console.log("Registration started")
let password = document.getElementById("user-pwd").value performGetSessionTokenRequest("register")
errorField = document.getElementById("login-error") }
console.log("Registration started"); async function deletePlayerAccount() {
const name = getPlayerName()
const password = getLoginPassword()
fetch("/create/user/" + username + "/" + password, { method: 'POST' }) fetch("/player/delete/" + username, { method: 'POST', body: password })
.then(function(response) {
if (response.status == 200) { // Success
return
}
if (response.status == 400) { // Bad request
throw Error("The request had an error")
}
if (response.status == 401) { // Invalid session token
throw Error("Please log in again")
}
if (response.status == 403) { // Forbidden
throw Error("The password or name is incorrect")
}
if (response.status == 424) { // Failed dependency
throw Error("The request couldn't be completed")
}
throw Error("Unexpected registration response: " + response.statusText)
}).then(function() {
showBlankLoginScreen("")
console.log("Player deleted")
}).catch(function(error) {
closeSocketIfNeeded()
deleteSessionToken()
alert(error.message)
console.log(error)
})
}
async function loginUser() {
console.log("Login started");
performGetSessionTokenRequest("login")
}
async function logoutUser() {
const token = getSessionToken()
if (token) {
console.log("Logging out player")
performLogoutRequest(token)
} else {
console.log("No player to log out")
showBlankLoginScreen("")
}
}
async function performLogoutRequest(token) {
fetch("/player/logout", { method: 'POST', body: token })
.then(function(response) {
if (response.status == 200) { // Success
return
}
if (response.status == 400) { // Bad request
throw Error("The request had an error")
}
throw Error("Unexpected logout response: " + response.statusText)
}).then(function() {
showBlankLoginScreen("")
console.log("Player logged out")
}).catch(function(error) {
showBlankLoginScreen(error.message)
console.log(error)
})
}
function convertServerResponse(response) {
if (response.status == 200) { // Success
return response.text()
}
if (response.status == 400) { // Bad request
throw Error("The request was malformed")
}
if (response.status == 403) { // Forbidden
throw Error("Invalid username or password")
}
if (response.status == 406) { // notAcceptable
throw Error("The password or name is too long")
}
if (response.status == 409) { // Conflict
throw Error("A user with the same name is already registered")
}
if (response.status == 424) { // Failed dependency
throw Error("The request couldn't be completed")
}
throw Error("Unexpected response: " + response.statusText)
}
async function performGetSessionTokenRequest(type) {
const username = getLoginName()
const password = getLoginPassword()
console.log("Performing request " + type);
fetch("/player/" + type + "/" + username, { method: 'POST', body: password })
.then(convertServerResponse)
.then(function(token) {
setSessionToken(token)
setPlayerName(username)
hideLoginWindow()
setLoginError("")
console.log(type + " successful")
performGetPublicTablesList(token)
openSocket(token)
}).catch(function(error) {
setLoginError(error.message)
console.log(error)
return
})
}
async function loadExistingSession() {
const token = getSessionToken()
if (token) {
console.log("Resuming session with token " + token)
resumeSession(token)
} else {
console.log("No session to resume")
showLoginWindow()
}
}
async function resumeSession(token) {
fetch("/player/resume", { method: 'POST', body: token })
.then(convertServerResponse)
.then(function(name) {
setPlayerName(name)
hideLoginWindow()
console.log("Session resumed")
performGetPublicTablesList(token)
openSocket(token)
}).catch(function(error) {
deleteSessionToken()
setLoginError(error.message)
showLoginWindow()
console.log(error)
return
})
}
async function openSocket(token) {
socket = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/session/start")
socket.onopen = function(e) {
socket.send(token);
};
socket.onmessage = function(event) {
// TODO: Handle server data
};
socket.onclose = function(event) {
if (event.wasClean) {
} else {
// e.g. server process killed or network down
// event.code is usually 1006 in this case
}
};
socket.onerror = function(error) {
// error.message
};
}
function createTable() {
const tableName = getTableName()
const isVisible = getTableVisibility()
const token = getSessionToken()
if (token) {
performCreateTableRequest(token, tableName, isVisible)
}
}
async function performCreateTableRequest(token, name, visibility) {
const vis = visibility ? "public" : "private";
fetch("/table/create/" + vis + "/" + name, { method: 'POST', body: token })
.then(function(response) { .then(function(response) {
if (response.status == 200) { // Success if (response.status == 200) { // Success
return response.text() return response.text()
@ -17,36 +272,70 @@ async function registerUser() {
if (response.status == 400) { // Bad request if (response.status == 400) { // Bad request
throw Error("The request had an error") throw Error("The request had an error")
} }
if (response.status == 409) { // Conflict if (response.status == 401) { // Token invalid
throw Error("A user with the same name is already registered") showBlankLoginScreen("")
return ""
} }
throw Error("Unexpected response: " + response.statusText) throw Error("Unexpected registration response: " + response.statusText)
}).then(function(text) { })
localStorage.setItem('token', text) .then(function(tableId) {
hideLoginWindow() if (tableId == "") {
console.log("Registered") return;
}
clearTableName()
console.log("Created table " + tableId)
}).catch(function(error) { }).catch(function(error) {
errorField.innerHTML = error.message showBlankLoginScreen(error.message)
console.log(error) console.log(error)
return return
}) })
} }
function loadExistingSession() { function refreshTables() {
console.log("Checking to resume session"); const token = getSessionToken()
const token = localStorage.getItem('token');
if (token) { if (token) {
console.log("Resuming session with token " + token); performGetPublicTablesList(token)
resumeSession(token); } else {
showBlankLoginScreen()
} }
} }
function resumeSession(token) { async function performGetPublicTablesList(token) {
fetch("/tables/public", { method: 'POST', body: token })
localStorage.removeItem('token'); .then(convertServerResponse)
hideLoginWindow() .then(function(text) {
const decoded = atob(text)
const json = JSON.parse(decoded);
const html = processTableList(json)
setTableListContent(html)
}).catch(function(error) {
showBlankLoginScreen(error.message)
console.log(error)
return
})
} }
function loginUser() { function processTableList(tables) {
var html = ""
for (let i = 0, len = tables.length, text = ""; i < len; i++) {
tableInfo = tables[i]
html += "<div class=\"table-row\">" +
"<button class=\"table-join-btn\" onclick=\"joinTable('" + tableInfo.id +
"')\">Join</button><div class=\"table-title\">" + tableInfo.name +
"</div><div class=\"table-subtitle\">Players: " + tableInfo.players.join(", ") + "</div></div>"
}
return html
}
function joinTable(tableId) {
const token = getSessionToken()
if (token) {
performJoinTableRequest(tableId, token)
} else {
showBlankLoginScreen()
}
}
async function performJoinTableRequest(tableId, token) {
} }

View File

@ -7,17 +7,38 @@
<link rel='stylesheet' type='text/css' media='screen' href='style.css'> <link rel='stylesheet' type='text/css' media='screen' href='style.css'>
</head> </head>
<body> <body>
<div class="signup-backdrop" id="signup-window"> <div id="top-bar">
<div class="signup-window-vertical"> <div id="player-info">
<div class="signup-window"> <div id="player-name"></div>
<button id="logout-button" class="standard-button" onclick="logoutUser()">Log out</button>
</div>
<div class="table-list-bar">
<input type="text" id="table-name-field" name="tablename" placeholder="Create new table..." required>
<input type="checkbox" id="table-public-checkbox" name="public-table" checked="checked">
<span id="table-public-label">Public</span>
<button id="create-table-button" class="standard-button" onclick="createTable()">Create table</button>
<button id="refresh-tables" class="standard-button" onclick="refreshTables()">Refresh</button>
</div>
</div>
<div id="table-list">
</div>
<div class="table-list-window" id="table-window">
</div>
<div class="signup-window" id="signup-window">
<div class="signup-window-vertical-center">
<div class="signup-window-inner">
<label for="usrname">Username</label> <label for="usrname">Username</label>
<input type="text" id="user-name" name="usrname" required> <input type="text" id="user-name" name="usrname" required>
<label for="psw">Password</label> <label for="psw">Password</label>
<input type="password" id="user-pwd" name="psw" required> <input type="password" id="user-pwd" name="psw" required>
<button class="login-buttons" onclick="registerUser()">Register</button> <button class="login-buttons standard-button" onclick="registerUser()">Register</button>
<button class="login-buttons" onclick="loginUser()">Log in</button> <button class="login-buttons standard-button" onclick="loginUser()">Log in</button>
<div id="login-error"></div> <div id="login-error"></div>
</div> </div>
</div> </div>

View File

@ -1,25 +1,57 @@
:root {
/* Color definitions for light mode */
--button-color: rgb(255, 172, 39);
--button-hover: rgb(255, 185, 72);
--button-text: rgb(0,0,0);
--standard-background: rgb(54, 54, 54);
--element-background: rgb(42, 42, 42);
--element-border: rgb(27, 27, 27);
--text-color: rgb(255,255,255);
}
/* Style all input fields */ /* Style all input fields */
.standard-button {
padding: 10px;
background-color: var(--button-color);
color: #000;
border-style: hidden;
border-radius: 5px;
font-size: medium;
}
.standard-button:hover {
background-color: var(--button-hover);
}
input { input {
width: 100%;
padding: 8px; padding: 8px;
border: 1px solid #ccc; border: 1px solid var(--element-border);
border-radius: 4px; border-radius: 4px;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--standard-background);
}
.signup-window input {
width: 100%;
margin-top: 6px; margin-top: 6px;
margin-bottom: 16px; margin-bottom: 16px;
} }
html * { html * {
font-family:-apple-system, BlinkMacSystemFont, "SF Hello", "Helvetica Neue", Helvetica, Arial, Verdana, sans-serif; font-family:-apple-system, BlinkMacSystemFont, "SF Hello", "Helvetica Neue", Helvetica, Arial, Verdana, sans-serif;
color: var(--text-color);
} }
body, html { body, html {
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
margin: 0px;
background-color: var(--standard-background);
} }
.signup-backdrop { .signup-window {
background-color: #fff; background-color: var(--standard-background);
height: 100%; height: 100%;
width: 100%; width: 100%;
top: 0; top: 0;
@ -28,34 +60,142 @@ body, html {
position: absolute; position: absolute;
} }
.signup-window-vertical { .signup-window-vertical-center {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
} }
.signup-window { .signup-window-inner {
background-color: #f1f1f1; background-color: var(--element-background);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
width: 300px; width: 300px;
border-radius: 10px; border-radius: 10px;
border-style: solid; border-style: solid;
border-width: thin; border-width: thin;
border-color: darkgray; border-color: var(--element-border);
padding: 10px; padding: 10px;
} }
.login-buttons { .login-buttons {
width: 100%; width: 100%;
margin-top: 5px;
}
#top-bar {
height: 50px;
background-color: var(--element-background);
padding: 0px;
}
#player-info {
position: absolute;
right: 8px;
height: 50px;
display: grid;
align-items: center;
grid-template-columns: auto auto;
column-gap: 5px;
}
#player-name {
text-align: right;
grid-column: 1;
}
#logout-button {
width: 80px;
height: 34px;
padding: 0px;
grid-column: 2;
}
.table-list-bar {
position: absolute;
width: 510px;
height: 40px;
top: 5px;
left: 10px;
display: grid;
grid-template-columns: 200px 30px 50px 140px 90px;
align-items: center;
justify-content: center;
justify-items: center;
}
#table-name-field {
width: 100%;
grid-column: 1;
}
#table-public-checkbox {
grid-column: 2;
margin-left: 16px;
}
#table-public-label {
grid-column: 3;
}
#create-table-button {
width: 120px;
grid-column: 4;
padding: 0px;
height: 34px;
}
#refresh-tables {
width: 90px;
grid-column: 5;
padding: 0px;
height: 34px;
}
#table-list {
margin: 20px;
padding-right: 20px;
}
.table-row {
width: 100%;
height: 40px;
padding: 10px; padding: 10px;
background-color: #ccc; margin-top: 10px;
color: #000; background-color: var(--element-background);
border-style: hidden; border-style: hidden;
border-radius: 5px; border-radius: 5px;
margin-top: 5px; font-size: medium;
display: grid;
grid-template-columns: 100px auto;
grid-template-rows: auto auto;
column-gap: 10px;
/* justify-items: left; */
}
.table-join-btn {
padding: 10px;
width: 100px;
background-color: var(--button-color);
color: var(--button-text);
border-style: hidden;
border-radius: 5px;
font-size: medium;
grid-column: 1;
grid-row: 1 / span 2;
}
.table-join-btn:hover {
background-color: var(--button-hover);
}
.table-title {
grid-column: 2;
grid-row: 1;
font-size: medium; font-size: medium;
} }
.login-buttons:hover { .table-subtitle {
background-color: #ddd; grid-column: 2;
grid-row: 2;
font-size: small;
} }

View File

@ -0,0 +1,14 @@
import Foundation
import Crypto
extension String {
/**
Create a new access token.
*/
static func newToken() -> String {
Crypto.SymmetricKey.init(size: .bits128).withUnsafeBytes {
$0.hexEncodedString()
}
}
}

View File

@ -1,62 +1,68 @@
import Foundation import Foundation
import Crypto import Vapor
let playerPerTable = 4 let playerPerTable = 4
typealias TableId = String
typealias TableName = String
final class Database { final class Database {
/// A mapping between usernames and their password hashes private let players: PlayerManagement
private var userPasswordHashes = [String: String]()
/// A mapping between usernames and generated access tokens for a session private let tables: TableManagement
private var authTokenForUser = [String: String]()
/// A reverse mapping between generated access tokens and usernames private var sessions: [SessionToken : WebSocket]
private var userForToken = [String: String]()
/// A list of table ids for public games
private var publicTables = Set<String>()
/// A mapping from table id to table name (for all tables)
private var tableNames = [String: String]()
/// A mapping from table id to participating players
private var tablePlayers = [String: [String]]()
/// A reverse list of players and their table id
private var playerTables = [String: String]()
init() { init() {
self.players = PlayerManagement()
self.tables = TableManagement()
self.sessions = [:]
// TODO: Load server data from disk
// TODO: Save data to disk
} }
/** // MARK: Players & Sessions
Check if a user exists.
- Parameter name: The name of the user func registerPlayer(named name: PlayerName, hash: PasswordHash) -> SessionToken? {
- Returns: true, if the user exists players.registerPlayer(named: name, hash: hash)
*/
func has(user: String) -> Bool {
userPasswordHashes[user] != nil
} }
/** func passwordHashForExistingPlayer(named name: PlayerName) -> PasswordHash? {
Get the password hash for a user, if the user exists. players.passwordHash(ofRegisteredPlayer: name)
- Parameter name: The name of the user
- Returns: The stored password hash, if the user exists
*/
func hash(ofUser name: String) -> String? {
userPasswordHashes[name]
} }
/** func deletePlayer(named name: PlayerName) {
Create a new user and assign an access token. if let sessionToken = players.deletePlayer(named: name) {
- Parameter name: The name of the new user closeAndRemoveSession(for: sessionToken)
- Parameter hash: The password hash of the user }
- Returns: The generated access token for the session // TODO: Delete player from tables
*/ }
func add(user name: String, hash: String) -> String {
self.userPasswordHashes[name] = hash func isValid(sessionToken token: SessionToken) -> Bool {
return startSession(forUser: name) players.isValid(sessionToken: token)
}
func startSession(socket: WebSocket, sessionToken: SessionToken) {
closeAndRemoveSession(for: sessionToken)
sessions[sessionToken] = socket
socket.onText { [weak self] socket, text in
self?.didReceive(message: text, forSessionToken: sessionToken)
}
}
private func didReceive(message: String, forSessionToken token: SessionToken) {
// TODO: Handle client requests
print("Session \(token.prefix(6)): \(message)")
}
func endSession(forSessionToken token: SessionToken) {
players.endSession(forSessionToken: token)
closeAndRemoveSession(for: token)
}
private func closeAndRemoveSession(for token: SessionToken) {
_ = sessions.removeValue(forKey: token)?.close()
} }
/** /**
@ -64,32 +70,22 @@ final class Database {
- Parameter name: The user name - Parameter name: The user name
- Returns: The generated access token for the session - Returns: The generated access token for the session
*/ */
func startSession(forUser name: String) -> String { func startNewSessionForRegisteredPlayer(named name: PlayerName) -> SessionToken {
let token = newToken() players.startNewSessionForRegisteredPlayer(named: name)
self.authTokenForUser[name] = token
self.userForToken[token] = name
return token
} }
/** func registeredPlayerExists(withSessionToken token: SessionToken) -> PlayerName? {
Get the user for a session token. players.registeredPlayerExists(withSessionToken: token)
- Parameter token: The access token for the user
- Returns: The name of the user, if it exists
*/
func user(forToken token: String) -> String? {
userForToken[token]
} }
func tableExists(named name: String) -> Bool { // MARK: Tables
tableNames.contains { $0.value == name }
func tableExists(withId id: TableId) -> Bool {
tables.tableExists(withId: id)
} }
func tableExists(withId id: String) -> Bool { func tableIsFull(withId id: TableId) -> Bool {
tableNames[id] != nil tables.tableIsFull(withId: id)
}
func tableIsFull(withId id: String) -> Bool {
tablePlayers[id]!.count < playerPerTable
} }
/** /**
@ -99,43 +95,22 @@ final class Database {
- Parameter visible: Indicates that this is a game joinable by everyone - Parameter visible: Indicates that this is a game joinable by everyone
- Returns: The table id - Returns: The table id
*/ */
func createTable(named name: String, player: String, visible: Bool) -> String { func createTable(named name: TableName, player: PlayerName, visible: Bool) -> TableId {
let tableId = newToken() tables.createTable(named: name, player: player, visible: visible)
tableNames[tableId] = tableId
tablePlayers[tableId] = [player]
playerTables[player] = tableId
if visible {
publicTables.insert(tableId)
}
return tableId
} }
func getPublicTableInfos() -> [TableInfo] { func getPublicTableInfos() -> [TableInfo] {
publicTables.map { tableId in tables.getPublicTableInfos()
TableInfo(id: tableId, name: tableNames[tableId]!, players: tablePlayers[tableId]!)
}.sorted()
} }
func join(tableId: String, player: String) { func join(tableId: TableId, player: PlayerName) {
tablePlayers[tableId]!.append(player) let playersAtTable = tables.join(tableId: tableId, player: player)
if let oldTable = playerTables[tableId] { playersAtTable
remove(player: player, fromTable: oldTable) .compactMap { players.sessionToken(forPlayer: $0) } // Session Tokens
} .compactMap { sessions[$0] } // Sockets
playerTables[tableId] = tableId .forEach { socket in
} // TODO: Notify sessions about changed players
// socket.send("")
func remove(player: String, fromTable tableId: String) { }
tablePlayers[tableId] = tablePlayers[tableId]?.filter { $0 != player }
}
/**
Create a new access token.
*/
private func newToken() -> String {
Crypto.SymmetricKey.init(size: .bits128).withUnsafeBytes {
$0.hexEncodedString()
}
} }
} }

View File

@ -0,0 +1,106 @@
import Foundation
typealias PlayerName = String
typealias PasswordHash = String
typealias SessionToken = String
/// Manages player registration, session tokens and password hashes
final class PlayerManagement {
/// A mapping between player name and their password hashes
private var playerPasswordHashes = [PlayerName: PasswordHash]()
/// A mapping between player name and generated access tokens for a session
private var sessionTokenForPlayer = [PlayerName: SessionToken]()
/// A reverse mapping between generated access tokens and player name
private var playerNameForToken = [SessionToken: PlayerName]()
init() {
}
/**
Check if a player exists.
- Parameter name: The name of the player
- Returns: true, if the player exists
*/
func hasRegisteredPlayer(named user: PlayerName) -> Bool {
playerPasswordHashes[user] != nil
}
/**
Get the password hash for a player, if the player exists.
- Parameter name: The name of the player
- Returns: The stored password hash, if the player exists
*/
func passwordHash(ofRegisteredPlayer name: PlayerName) -> PasswordHash? {
playerPasswordHashes[name]
}
/**
Create a new player and assign an access token.
- Parameter name: The name of the new player
- Parameter hash: The password hash of the player
- Returns: The generated access token for the session
*/
func registerPlayer(named name: PlayerName, hash: PasswordHash) -> SessionToken? {
guard !hasRegisteredPlayer(named: name) else {
return nil
}
self.playerPasswordHashes[name] = hash
return startNewSessionForRegisteredPlayer(named: name)
}
/**
Delete a player
- Parameter name: The name of the player to delete.
- Returns: The session token of the current player, if one exists
*/
func deletePlayer(named name: PlayerName) -> SessionToken? {
playerPasswordHashes.removeValue(forKey: name)
guard let sessionToken = sessionTokenForPlayer.removeValue(forKey: name) else {
return nil
}
playerNameForToken.removeValue(forKey: sessionToken)
return sessionToken
}
func isValid(sessionToken token: SessionToken) -> Bool {
playerNameForToken[token] != nil
}
func sessionToken(forPlayer player: PlayerName) -> SessionToken? {
sessionTokenForPlayer[player]
}
/**
Start a new session for an existing player.
- Parameter name: The player name
- Returns: The generated access token for the session
*/
func startNewSessionForRegisteredPlayer(named name: PlayerName) -> SessionToken {
let token = SessionToken.newToken()
self.sessionTokenForPlayer[name] = token
self.playerNameForToken[token] = name
return token
}
func endSession(forSessionToken token: SessionToken) {
guard let player = playerNameForToken.removeValue(forKey: token) else {
return
}
sessionTokenForPlayer.removeValue(forKey: player)
}
/**
Get the player for a session token.
- Parameter token: The access token for the player
- Returns: The name of the player, if it exists
*/
func registeredPlayerExists(withSessionToken token: SessionToken) -> PlayerName? {
playerNameForToken[token]
}
}

View File

@ -0,0 +1,75 @@
import Foundation
final class TableManagement {
/// A list of table ids for public games
private var publicTables = Set<TableId>()
/// A mapping from table id to table name (for all tables)
private var tableNames = [TableId: TableName]()
/// A mapping from table id to participating players
private var tablePlayers = [TableId: [PlayerName]]()
/// A reverse list of players and their table id
private var playerTables = [PlayerName: TableId]()
init() {
}
func tableExists(withId id: TableId) -> Bool {
tableNames[id] != nil
}
func tableIsFull(withId id: TableId) -> Bool {
(tablePlayers[id]?.count ?? playerPerTable) < playerPerTable
}
/**
Create a new table with optional players.
- Parameter name: The name of the table
- Parameter players: The player creating the table
- Parameter visible: Indicates that this is a game joinable by everyone
- Returns: The table id
*/
func createTable(named name: TableName, player: PlayerName, visible: Bool) -> TableId {
let tableId = TableId.newToken()
tableNames[tableId] = name
tablePlayers[tableId] = [player]
playerTables[player] = tableId
if visible {
publicTables.insert(tableId)
}
return tableId
}
func getPublicTableInfos() -> [TableInfo] {
publicTables.map { tableId in
TableInfo(id: tableId, name: tableNames[tableId]!, players: tablePlayers[tableId]!)
}.sorted()
}
/**
Join a table.
- Returns: The player names present at the table
*/
func join(tableId: TableId, player: PlayerName) -> [PlayerName] {
guard var players = tablePlayers[tableId] else {
return []
}
players.append(player)
if let oldTable = playerTables[tableId] {
remove(player: player, fromTable: oldTable)
}
tablePlayers[tableId] = players
playerTables[tableId] = tableId
return players
}
func remove(player: PlayerName, fromTable tableId: TableId) {
tablePlayers[tableId] = tablePlayers[tableId]?.filter { $0 != player }
}
}

View File

@ -1,99 +1,230 @@
import Vapor import Vapor
/// The JSON encoder for responses
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
/// The maximum length of a valid player name
private let maximumPlayerNameLength = 40
/// The maximum length of a valid password
private let maximumPasswordLength = 40
func routes(_ app: Application) throws { func routes(_ app: Application) throws {
app.get { req in
return "It works!"
}
app.post("create", "user", ":name", ":hash") { req -> String in // MARK: Players & Sessions
/**
Create a new player
- Parameter name: The name of the player, included in the url
- Parameter password: The password of the player, as a string in the request body
- Throws:
- 400: Missing name or password
- 406: Password or name too long
- 409: A player with the same name already exists
- 424: The password could not be hashed
- Returns: The session token for the registered user
*/
app.post("player", "register", ":name") { req -> String in
guard let name = req.parameters.get("name"), guard let name = req.parameters.get("name"),
let hash = req.parameters.get("hash") else { let password = req.body.string else {
throw Abort(.badRequest) throw Abort(.badRequest) // 400
} }
let digest = try req.password.hash(hash) guard name.count < maximumPlayerNameLength,
password.count < maximumPasswordLength else {
throw Abort(.notAcceptable) // 406
}
guard !database.has(user: name) else { guard let hash = try? req.password.hash(password) else {
throw Abort(.conflict) throw Abort(.failedDependency) // 424
}
guard let token = database.registerPlayer(named: name, hash: hash) else {
throw Abort(.conflict) // 409
} }
let token = database.add(user: name, hash: digest)
return token return token
} }
app.get("create", "session", ":name", ":hash") { req -> String in /**
Delete a player.
- Parameter name: The name of the player, included in the url
- Parameter password: The password of the player, as a string in the request body
- Throws:
- 400: Missing name or password
- 403: The password or user name is invalid
- 424: The password could not be hashed
- Returns: Nothing
*/
app.post("player", "delete", ":name") { req -> String in
guard let name = req.parameters.get("name"), guard let name = req.parameters.get("name"),
let hash = req.parameters.get("hash") else { let password = req.body.string else {
throw Abort(.badRequest) throw Abort(.badRequest) // 400
}
guard let hash = database.passwordHashForExistingPlayer(named: name) else {
throw Abort(.forbidden) // 403
} }
guard let digest = database.hash(ofUser: name), guard let isValid = try? req.password.verify(password, created: hash) else {
try req.password.verify(hash, created: digest) else { throw Abort(.failedDependency) // 424
throw Abort(.forbidden)
} }
let token = database.startSession(forUser: name) guard isValid else {
throw Abort(.forbidden) // 403
}
database.deletePlayer(named: name)
return ""
}
/**
Log in as an existing player.
- Parameter name: The name of the player, included in the url
- Parameter password: The password of the player, as a string in the request body
- Throws:
- 400: Missing name or password
- 403: The password or user name is invalid
- 424: The password could not be hashed
- Returns: The session token for the user
*/
app.post("player", "login", ":name") { req -> String in
guard let name = req.parameters.get("name"),
let password = req.body.string else {
throw Abort(.badRequest) // 400
}
guard let hash = database.passwordHashForExistingPlayer(named: name) else {
throw Abort(.forbidden) // 403
}
guard let isValid = try? req.password.verify(password, created: hash) else {
throw Abort(.failedDependency) // 424
}
guard isValid else {
throw Abort(.forbidden) // 403
}
let token = database.startNewSessionForRegisteredPlayer(named: name)
return token return token
} }
app.get("session", "resume", ":token") { req -> String in /**
guard let token = req.parameters.get("token") else { Log in using a session token.
throw Abort(.badRequest) - Parameter token: The session token of the player, as a string in the request body
- Throws:
- 400: Missing token
- 401: The token is invalid
- Returns: The player name associated with the session token
*/
app.post("player", "resume") { req -> String in
guard let token = req.body.string else {
throw Abort(.badRequest) // 400
} }
guard let user = database.user(forToken: token) else { guard let player = database.registeredPlayerExists(withSessionToken: token) else {
throw Abort(.forbidden) throw Abort(.unauthorized) // 401
} }
return user return player
} }
// TODO: Improve token handling (it will be logged when included in url!) /**
app.get("create", "table", ":visibility", ":name", ":token") { req -> String in Log out.
guard let name = req.parameters.get("name"), - Parameter name: The name of the player, included in the url
let token = req.parameters.get("token"), - Parameter token: The session token of the player, as a string in the request body
let visibility = req.parameters.get("visibility") else { - Throws:
throw Abort(.badRequest) - 400: Missing token
- Returns: Nothing
- Note: The request always succeeds when correctly formed, even for invalid and expired tokens
*/
app.post("player", "logout") { req -> String in
guard let token = req.body.string else {
throw Abort(.badRequest) // 400
} }
database.endSession(forSessionToken: token)
return ""
}
/**
Start a new bidirectional session connection.
- Returns: Nothing
- Note: The first message over the connection must be a valid session token.
*/
app.webSocket("session", "start") { req, socket in
socket.onText { socket, text in
guard database.isValid(sessionToken: text) else {
_ = socket.close()
return
}
database.startSession(socket: socket, sessionToken: text)
}
}
// MARK: Tables
/**
Create a new table.
- Parameter visibility: Indicate a `"public"` or `"private"` table
- Parameter token: The session token of the player, as a string in the request body
- Returns: The table id
- Throws:
- 400: Missing token, table name or invalid visibility
- 401: The session token is invalid
*/
app.post("table", "create", ":visibility", ":name") { req -> String in
guard let visibility = req.parameters.get("visibility"),
let tableName = req.parameters.get("name"),
let token = req.body.string else {
throw Abort(.badRequest) // 400
}
let isVisible: Bool let isVisible: Bool
if visibility == "private" { if visibility == "private" {
isVisible = false isVisible = false
} else if visibility == "public" { } else if visibility == "public" {
isVisible = true isVisible = true
} else { } else {
throw Abort(.badRequest) throw Abort(.badRequest) // 400
} }
guard let user = database.user(forToken: token) else { guard let player = database.registeredPlayerExists(withSessionToken: token) else {
throw Abort(.forbidden) throw Abort(.unauthorized) // 401
} }
guard !database.tableExists(named: name) else { let tableId = database.createTable(named: tableName, player: player, visible: isVisible)
throw Abort(.conflict)
}
let tableId = database.createTable(named: name, player: user, visible: isVisible)
return tableId return tableId
} }
app.get("tables", "public", ":token") { req -> String in /**
guard let token = req.parameters.get("token") else { List the public tables.
throw Abort(.badRequest) - Parameter token: The session token of the player, as a string in the request body
- Throws:
- 400: Missing token
- 403: The session token is invalid
- Returns: A JSON object with a list of public tables (id, name, player list)
*/
app.post("tables", "public") { req -> String in
guard let token = req.body.string else {
throw Abort(.badRequest) // 400
} }
guard let _ = database.user(forToken: token) else { guard database.isValid(sessionToken: token) else {
throw Abort(.forbidden) throw Abort(.forbidden) // 403
} }
let list = database.getPublicTableInfos() let list = database.getPublicTableInfos()
return try encoder.encode(list).base64EncodedString() return try encoder.encode(list).base64EncodedString()
} }
app.post("table", "join", ":table", ":token") { req -> String in /**
Join a table.
- Parameter table: The table id
- Parameter token: The session token of the player, as a string in the request body
- Throws:
- 400: Missing token
- 401: The session token is invalid
- 404: The table id doesn't exist
- 406: The table is already full and can't be joined
- Returns: Nothing
*/
app.post("table", "join", ":table") { req -> String in
guard let table = req.parameters.get("table"), guard let table = req.parameters.get("table"),
let token = req.parameters.get("token") else { let token = req.body.string else {
throw Abort(.badRequest) throw Abort(.badRequest)
} }
guard let player = database.user(forToken: token) else { guard let player = database.registeredPlayerExists(withSessionToken: token) else {
throw Abort(.forbidden) throw Abort(.unauthorized) // 401
} }
guard database.tableExists(withId: table) else { guard database.tableExists(withId: table) else {
throw Abort(.notFound) throw Abort(.notFound) // 404
} }
guard !database.tableIsFull(withId: table) else { guard !database.tableIsFull(withId: table) else {
throw Abort(.notAcceptable) throw Abort(.notAcceptable) // 406
} }
database.join(tableId: table, player: player) database.join(tableId: table, player: player)
return "" return ""