diff --git a/.gitignore b/.gitignore index 5b7445c..68d8b65 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ DerivedData/ db.sqlite .swiftpm +Package.resolved diff --git a/Public/game.js b/Public/game.js new file mode 100644 index 0000000..b248d59 --- /dev/null +++ b/Public/game.js @@ -0,0 +1,52 @@ +function hideLoginWindow() { + document.getElementById("signup-window").style.display = "none" +} + +async function registerUser() { + let username = document.getElementById("user-name").value + let password = document.getElementById("user-pwd").value + errorField = document.getElementById("login-error") + + console.log("Registration started"); + + fetch("/create/user/" + username + "/" + password, { method: 'POST' }) + .then(function(response) { + if (response.status == 200) { // Success + return response.text() + } + if (response.status == 400) { // Bad request + throw Error("The request had an error") + } + if (response.status == 409) { // Conflict + throw Error("A user with the same name is already registered") + } + throw Error("Unexpected response: " + response.statusText) + }).then(function(text) { + localStorage.setItem('token', text) + hideLoginWindow() + console.log("Registered") + }).catch(function(error) { + errorField.innerHTML = error.message + console.log(error) + return + }) +} + +function loadExistingSession() { + console.log("Checking to resume session"); + const token = localStorage.getItem('token'); + if (token) { + console.log("Resuming session with token " + token); + resumeSession(token); + } +} + +function resumeSession(token) { + + localStorage.removeItem('token'); + hideLoginWindow() +} + +function loginUser() { + +} diff --git a/Public/schafkopf.html b/Public/schafkopf.html new file mode 100644 index 0000000..905e2df --- /dev/null +++ b/Public/schafkopf.html @@ -0,0 +1,30 @@ + + + + + Schafkopf + + + + +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/Public/style.css b/Public/style.css new file mode 100644 index 0000000..d714f76 --- /dev/null +++ b/Public/style.css @@ -0,0 +1,61 @@ +/* Style all input fields */ +input { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + margin-top: 6px; + margin-bottom: 16px; +} + +html * { + font-family:-apple-system, BlinkMacSystemFont, "SF Hello", "Helvetica Neue", Helvetica, Arial, Verdana, sans-serif; +} + +body, html { + min-height: 100%; + height: 100%; +} + +.signup-backdrop { + background-color: #fff; + height: 100%; + width: 100%; + top: 0; + left: 0; + display: table; + position: absolute; +} + +.signup-window-vertical { + display: table-cell; + vertical-align: middle; +} + +.signup-window { + background-color: #f1f1f1; + margin-left: auto; + margin-right: auto; + width: 300px; + border-radius: 10px; + border-style: solid; + border-width: thin; + border-color: darkgray; + padding: 10px; +} + +.login-buttons { + width: 100%; + padding: 10px; + background-color: #ccc; + color: #000; + border-style: hidden; + border-radius: 5px; + margin-top: 5px; + font-size: medium; +} + +.login-buttons:hover { + background-color: #ddd; +} \ No newline at end of file diff --git a/Sources/App/Model/Database.swift b/Sources/App/Model/Database.swift new file mode 100644 index 0000000..6edf5c3 --- /dev/null +++ b/Sources/App/Model/Database.swift @@ -0,0 +1,141 @@ +import Foundation +import Crypto + +let playerPerTable = 4 + +final class Database { + + /// A mapping between usernames and their password hashes + private var userPasswordHashes = [String: String]() + + /// A mapping between usernames and generated access tokens for a session + private var authTokenForUser = [String: String]() + + /// A reverse mapping between generated access tokens and usernames + private var userForToken = [String: String]() + + /// A list of table ids for public games + private var publicTables = Set() + + /// 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() { + + } + + /** + Check if a user exists. + - Parameter name: The name of the user + - Returns: true, if the user exists + */ + func has(user: String) -> Bool { + userPasswordHashes[user] != nil + } + + /** + Get the password hash for a user, if the user exists. + - Parameter name: The name of the user + - Returns: The stored password hash, if the user exists + */ + func hash(ofUser name: String) -> String? { + userPasswordHashes[name] + } + + /** + Create a new user and assign an access token. + - Parameter name: The name of the new user + - Parameter hash: The password hash of the user + - Returns: The generated access token for the session + */ + func add(user name: String, hash: String) -> String { + self.userPasswordHashes[name] = hash + return startSession(forUser: name) + } + + /** + Start a new session for an existing user. + - Parameter name: The user name + - Returns: The generated access token for the session + */ + func startSession(forUser name: String) -> String { + let token = newToken() + self.authTokenForUser[name] = token + self.userForToken[token] = name + return token + } + + /** + Get the user for a session 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 { + tableNames.contains { $0.value == name } + } + + func tableExists(withId id: String) -> Bool { + tableNames[id] != nil + } + + func tableIsFull(withId id: String) -> Bool { + tablePlayers[id]!.count < 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: String, player: String, visible: Bool) -> String { + let tableId = newToken() + + tableNames[tableId] = tableId + 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() + } + + func join(tableId: String, player: String) { + tablePlayers[tableId]!.append(player) + if let oldTable = playerTables[tableId] { + remove(player: player, fromTable: oldTable) + } + playerTables[tableId] = tableId + } + + 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() + } + } +} diff --git a/Sources/App/Model/Table.swift b/Sources/App/Model/Table.swift new file mode 100644 index 0000000..6652d08 --- /dev/null +++ b/Sources/App/Model/Table.swift @@ -0,0 +1,17 @@ +import Foundation + +struct TableInfo: Codable { + + let id: String + + let name: String + + var players: [String] +} + +extension TableInfo: Comparable { + + static func < (lhs: TableInfo, rhs: TableInfo) -> Bool { + lhs.name < rhs.name + } +} diff --git a/Sources/App/Model/User.swift b/Sources/App/Model/User.swift new file mode 100644 index 0000000..fb6c44d --- /dev/null +++ b/Sources/App/Model/User.swift @@ -0,0 +1,9 @@ +import Foundation + + +struct User: Codable { + + let name: String + + let passwordHash: Data +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 800d36c..78f9455 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,10 +1,13 @@ import Vapor +var database: Database! + // configures your application public func configure(_ app: Application) throws { - // uncomment to serve files from /Public folder - // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + // serve files from /Public folder + app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + database = Database() // register routes try routes(app) } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 6bb9c5c..d318280 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -1,11 +1,101 @@ import Vapor +private let encoder = JSONEncoder() + func routes(_ app: Application) throws { app.get { req in return "It works!" } - app.get("hello") { req -> String in - return "Hello, world!" + app.post("create", "user", ":name", ":hash") { req -> String in + guard let name = req.parameters.get("name"), + let hash = req.parameters.get("hash") else { + throw Abort(.badRequest) + } + let digest = try req.password.hash(hash) + + guard !database.has(user: name) else { + throw Abort(.conflict) + } + let token = database.add(user: name, hash: digest) + return token + } + + app.get("create", "session", ":name", ":hash") { req -> String in + guard let name = req.parameters.get("name"), + let hash = req.parameters.get("hash") else { + throw Abort(.badRequest) + } + guard let digest = database.hash(ofUser: name), + try req.password.verify(hash, created: digest) else { + throw Abort(.forbidden) + } + let token = database.startSession(forUser: name) + return token + } + + app.get("session", "resume", ":token") { req -> String in + guard let token = req.parameters.get("token") else { + throw Abort(.badRequest) + } + guard let user = database.user(forToken: token) else { + throw Abort(.forbidden) + } + return user + } + + // TODO: Improve token handling (it will be logged when included in url!) + app.get("create", "table", ":visibility", ":name", ":token") { req -> String in + guard let name = req.parameters.get("name"), + let token = req.parameters.get("token"), + let visibility = req.parameters.get("visibility") else { + throw Abort(.badRequest) + } + let isVisible: Bool + if visibility == "private" { + isVisible = false + } else if visibility == "public" { + isVisible = true + } else { + throw Abort(.badRequest) + } + + guard let user = database.user(forToken: token) else { + throw Abort(.forbidden) + } + guard !database.tableExists(named: name) else { + throw Abort(.conflict) + } + let tableId = database.createTable(named: name, player: user, visible: isVisible) + return tableId + } + + app.get("tables", "public", ":token") { req -> String in + guard let token = req.parameters.get("token") else { + throw Abort(.badRequest) + } + guard let _ = database.user(forToken: token) else { + throw Abort(.forbidden) + } + let list = database.getPublicTableInfos() + return try encoder.encode(list).base64EncodedString() + } + + app.post("table", "join", ":table", ":token") { req -> String in + guard let table = req.parameters.get("table"), + let token = req.parameters.get("token") else { + throw Abort(.badRequest) + } + guard let player = database.user(forToken: token) else { + throw Abort(.forbidden) + } + guard database.tableExists(withId: table) else { + throw Abort(.notFound) + } + guard !database.tableIsFull(withId: table) else { + throw Abort(.notAcceptable) + } + database.join(tableId: table, player: player) + return "" } }