import Foundation import Fluent import Vapor import SwiftSMTP import Clairvoyant typealias PasswordHash = String typealias SessionToken = String private struct MailConfig { /// The url to the root of the server let serverDomain: String /// The number of minutes until a password reset token is no longer valid let tokenExpiryDuration: Int let smtp: SMTP let mailSender: Mail.User } private extension Configuration.EMail { var mailConfig: MailConfig { let smtp = SMTP( hostname: emailHostname, email: email, password: password) let mailSender = Mail.User(name: "Schafkopf Server", email: email) return .init( serverDomain: serverDomain, tokenExpiryDuration: tokenExpiryDuration, smtp: smtp, mailSender: mailSender) } } final class SQLiteDatabase { /// 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]() private let tables: TableManagement private let mail: MailConfig? private let registeredPlayerCountMetric: Metric init(database: Database, mail: Configuration.EMail?) async throws { self.registeredPlayerCountMetric = try await Metric( "schafkopf.players", name: "Number of users", description: "The total number of user accounts") self.tables = try await TableManagement(database: database) self.mail = mail?.mailConfig await updateRegisteredPlayerCount(from: database) } func registerPlayer(named name: PlayerName, hash: PasswordHash, email: String?, in database: Database) async throws -> SessionToken { if let email { let user = try await User .query(on: database) .filter(\.$recoveryEmail == email) .first() if user != nil { throw Abort(.conflict) } } if try await User.query(on: database).filter(\.$name == name).first() != nil { throw Abort(.conflict) } let user = User(name: name, hash: hash, email: email) try await user.create(on: database) // Create a new token and store it for the user let token = SessionToken.newToken() self.sessionTokenForPlayer[name] = token self.playerNameForToken[token] = name await updateRegisteredPlayerCount(from: database) return token } func updateRegisteredPlayerCount(from database: Database) async { do { let count = try await User.query(on: database).count() try? await registeredPlayerCountMetric.update(count) } catch { log("Failed to update player count metric: \(error)") } } /** Send a password reset email. Possible errors: - `417`: Player name or email not found. */ func sendPasswordResetEmailIfPossible(name: PlayerName, request: Request) async throws { guard let user = try await User.query(on: request.db).filter(\.$name == name).first() else { throw Abort(.expectationFailed) } guard let email = user.recoveryEmail else { throw Abort(.expectationFailed) } try await user.$resetRequest.load(on: request.db) if let reset = user.resetRequest { reset.renew() try await reset.save(on: request.db) self.sendEmail(name: name, email: email, token: reset.resetToken) } else { let reset = PasswordReset() try await user.$resetRequest.create(reset, on: request.db) self.sendEmail(name: name, email: email, token: reset.resetToken) } } private func sendEmail(name: PlayerName, email: String, token: String) { guard let mailData = mail else { log("Recovery email not set up") return } let recipient = Mail.User(name: name, email: email) //let mailConfiguration = mailData.mailConfiguration let url = "\(mailData.serverDomain)/recovery.html?token=\(token)" let mail = Mail( from: mailData.mailSender, to: [recipient], subject: "Schafkopf Server Password Reset", text: """ Hello \(name), a reset of your account password has been requested for the Schafkopf Server at \(mailData.serverDomain). To choose a new password, click the following link: \(url) The link will expire in \(mailData.tokenExpiryDuration) minutes. If you didn't request the password reset, you don't have to do anything. Regards, The Schafkopf Server Team """ ) mailData.smtp.send(mail) { (error) in if let error = error { log("Failed to send recovery email to \(email): \(error)") } } } /** Change the password of a user with a recovery token Possible errors: - `417`: Reset token not found or expired */ func updatePassword(password: String, forResetToken token: String, in database: Database) async throws { // 1. Find and validate the reset request let reset: PasswordReset? = try await PasswordReset.query(on: database) .filter(\.$resetToken == token) .first() guard let reset else { throw Abort(.expectationFailed) } guard reset.expiryDate.timeIntervalSinceNow > 0 else { throw Abort(.expectationFailed) } // 2. Update the user password let user = try await reset.$user.get(on: database) user.passwordHash = password try await user.save(on: database) // 3. Delete the reset request try await PasswordReset .query(on: database) .filter(\.$resetToken == token) .delete() } func passwordHashForExistingPlayer(named name: PlayerName, in database: Database) async throws -> PasswordHash { try await User .query(on: database) .filter(\.$name == name) .first() .unwrap(or: Abort(.unauthorized)) .passwordHash } func deletePlayer(named name: PlayerName, in database: Database) async throws { let user = try await user(named: name, in: database) try await tables.leaveTable(player: user, in: database) try await User.query(on: database).filter(\.$name == name).delete() await updateRegisteredPlayerCount(from: database) } func isValid(sessionToken token: SessionToken) -> Bool { playerNameForToken[token] != nil } func startSession(socket: WebSocket, sessionToken token: SessionToken) -> Bool { guard let player = playerNameForToken[token] else { return false } return tables.connect(player: player, using: socket) } func endSession(forSessionToken sessionToken: SessionToken) { guard let player = endExistingSession(forSessionToken: sessionToken) else { return } tables.disconnect(player: player) } private func endExistingSession(forSessionToken token: SessionToken) -> PlayerName? { guard let player = playerNameForToken.removeValue(forKey: token) else { return nil } sessionTokenForPlayer.removeValue(forKey: player) return player } /** Start a new session for an existing user. - Parameter name: The user 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 registeredPlayerExists(withSessionToken token: SessionToken) -> PlayerName? { playerNameForToken[token] } func currentTableOfPlayer(named player: PlayerName) -> TableInfo? { tables.tableInfo(player: player) } private func points(for player: PlayerName, in database: Database) async throws -> Int { try await User.query(on: database) .filter(\.$name == player) .first() .unwrap(or: Abort(.notFound)) .points } private func user(named name: PlayerName, in database: Database) async throws -> User { try await User .query(on: database) .filter(\.$name == name) .first() .unwrap(or: Abort(.notFound)) } private func user(withToken token: SessionToken, in database: Database) async throws -> User { let name = try playerNameForToken[token].unwrap(or: Abort(.unauthorized)) return try await user(named: name, in: database) } // MARK: Tables /** Create a new table with optional players. - Parameter name: The name of the table - Parameter players: The player creating the table - Parameter isPublic: Indicates that this is a game joinable by everyone - Returns: The table id */ func createTable(named name: TableName, player: PlayerName, isPublic: Bool, in database: Database) async throws -> TableInfo { let user = try await user(named: player, in: database) return try await tables.createTable(named: name, player: user, isPublic: isPublic, in: database) } func getPublicTableInfos() -> [PublicTableInfo] { tables.publicTableList } func join(tableId: UUID, playerToken: SessionToken, in database: Database) async throws -> TableInfo { let user = try await user(withToken: playerToken, in: database) return try await tables.join(tableId: tableId, player: user, in: database) } func leaveTable(playerToken: SessionToken, in database: Database) async throws { let user = try await user(withToken: playerToken, in: database) try await tables.leaveTable(player: user, in: database) } func performAction(playerToken: SessionToken, action: PlayerAction) -> PlayerActionResult { guard let player = playerNameForToken[playerToken] else { return .invalidToken } return tables.performAction(player: player, action: action) } func select(game: GameType, playerToken: SessionToken) -> PlayerActionResult { guard let player = playerNameForToken[playerToken] else { return .invalidToken } return tables.select(game: game, player: player) } func play(card: Card, playerToken: SessionToken, in database: Database) async throws -> PlayerActionResult { guard let player = playerNameForToken[playerToken] else { return .invalidToken } return try await tables.play(card: card, player: player, in: database) } func disconnectAllSockets() { tables.disconnectAllSockets() } }