Schafkopf-Server/Sources/App/Management/SQLiteDatabase.swift

327 lines
11 KiB
Swift
Raw Normal View History

2021-12-22 22:11:37 +01:00
import Foundation
import Fluent
import Vapor
import SwiftSMTP
2023-02-01 16:44:07 +01:00
import Clairvoyant
2021-12-22 22:11:37 +01:00
typealias PasswordHash = String
typealias SessionToken = String
2022-11-24 21:31:03 +01:00
private struct MailConfig {
2023-02-01 16:44:07 +01:00
/// 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
2022-11-24 21:31:03 +01:00
let smtp: SMTP
let mailSender: Mail.User
}
2023-02-01 16:44:07 +01:00
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)
}
}
2021-12-22 22:11:37 +01:00
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
2022-11-24 21:31:03 +01:00
private let mail: MailConfig?
2023-02-01 16:44:07 +01:00
private let registeredPlayerCountMetric: Metric<Int>
2023-02-16 18:31:30 +01:00
init(database: Database, mail: Configuration.EMail?) async throws {
self.registeredPlayerCountMetric = try await Metric(
2023-02-06 11:25:30 +01:00
"schafkopf.players",
name: "Number of users",
description: "The total number of user accounts")
2023-02-16 18:31:30 +01:00
self.tables = try await TableManagement(database: database)
2023-02-01 16:44:07 +01:00
self.mail = mail?.mailConfig
2022-11-24 21:31:03 +01:00
2023-02-16 18:31:30 +01:00
await updateRegisteredPlayerCount(from: database)
2021-12-22 22:11:37 +01:00
}
2022-10-12 14:34:43 +02:00
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)
2021-12-22 22:11:37 +01:00
}
2022-10-12 14:34:43 +02:00
}
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
2023-02-01 16:44:07 +01:00
await updateRegisteredPlayerCount(from: database)
2022-10-12 14:34:43 +02:00
return token
}
2022-10-12 19:30:11 +02:00
2023-02-01 16:44:07 +01:00
func updateRegisteredPlayerCount(from database: Database) async {
do {
let count = try await User.query(on: database).count()
2023-02-16 18:31:30 +01:00
try? await registeredPlayerCountMetric.update(count)
2023-02-01 16:44:07 +01:00
} catch {
2023-02-06 22:03:02 +01:00
log("Failed to update player count metric: \(error)")
2023-02-01 16:44:07 +01:00
}
}
2022-10-12 19:30:11 +02:00
/**
Send a password reset email.
Possible errors:
2022-10-12 19:43:16 +02:00
- `417`: Player name or email not found.
2022-10-12 19:30:11 +02:00
*/
func sendPasswordResetEmailIfPossible(name: PlayerName, request: Request) async throws {
guard let user = try await User.query(on: request.db).filter(\.$name == name).first() else {
2022-10-12 19:43:16 +02:00
throw Abort(.expectationFailed)
2022-10-12 19:30:11 +02:00
}
guard let email = user.recoveryEmail else {
2022-10-12 19:43:16 +02:00
throw Abort(.expectationFailed)
2022-10-12 19:30:11 +02:00
}
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)
2022-10-12 19:30:11 +02:00
} else {
let reset = PasswordReset()
try await user.$resetRequest.create(reset, on: request.db)
2022-10-12 19:30:11 +02:00
self.sendEmail(name: name, email: email, token: reset.resetToken)
}
2021-12-22 22:11:37 +01:00
}
private func sendEmail(name: PlayerName, email: String, token: String) {
2022-11-24 21:31:03 +01:00
guard let mailData = mail else {
2023-02-06 22:03:02 +01:00
log("Recovery email not set up")
2022-11-24 21:31:03 +01:00
return
}
let recipient = Mail.User(name: name, email: email)
2023-02-01 16:44:07 +01:00
//let mailConfiguration = mailData.mailConfiguration
let url = "\(mailData.serverDomain)/recovery.html?token=\(token)"
let mail = Mail(
2022-11-24 21:31:03 +01:00
from: mailData.mailSender,
to: [recipient],
subject: "Schafkopf Server Password Reset",
text:
"""
Hello \(name),
2023-02-01 16:44:07 +01:00
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:
2022-10-12 19:30:11 +02:00
\(url)
2023-02-01 16:44:07 +01:00
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
"""
)
2022-11-24 21:31:03 +01:00
mailData.smtp.send(mail) { (error) in
if let error = error {
2023-02-06 22:03:02 +01:00
log("Failed to send recovery email to \(email): \(error)")
}
}
}
2022-10-12 19:28:28 +02:00
/**
Change the password of a user with a recovery token
2021-12-22 22:11:37 +01:00
Possible errors:
2022-10-12 19:43:16 +02:00
- `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)
2021-12-22 22:11:37 +01:00
}
// 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()
2022-10-12 19:43:16 +02:00
.unwrap(or: Abort(.unauthorized))
.passwordHash
}
2022-10-12 19:28:28 +02:00
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()
2023-02-01 16:44:07 +01:00
await updateRegisteredPlayerCount(from: database)
2021-12-22 22:11:37 +01:00
}
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)
}
2022-10-12 19:28:28 +02:00
private func points(for player: PlayerName, in database: Database) async throws -> Int {
try await User.query(on: database)
2021-12-22 22:11:37 +01:00
.filter(\.$name == player)
.first()
.unwrap(or: Abort(.notFound))
2022-10-12 19:28:28 +02:00
.points
2021-12-22 22:11:37 +01:00
}
2022-10-12 19:28:28 +02:00
private func user(named name: PlayerName, in database: Database) async throws -> User {
try await User
.query(on: database)
2021-12-22 22:11:37 +01:00
.filter(\.$name == name)
.first()
.unwrap(or: Abort(.notFound))
}
2022-10-12 19:28:28 +02:00
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)
2021-12-22 22:11:37 +01:00
}
// 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
*/
2022-10-12 19:28:28 +02:00
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)
2021-12-22 22:11:37 +01:00
}
func getPublicTableInfos() -> [PublicTableInfo] {
tables.publicTableList
}
2022-10-12 19:28:28 +02:00
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)
2021-12-22 22:11:37 +01:00
}
2022-10-12 19:28:28 +02:00
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)
2021-12-22 22:11:37 +01:00
}
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)
}
2022-10-18 11:40:08 +02:00
func play(card: Card, playerToken: SessionToken, in database: Database) async throws -> PlayerActionResult {
2021-12-22 22:11:37 +01:00
guard let player = playerNameForToken[playerToken] else {
return .invalidToken
}
2022-10-18 11:40:08 +02:00
return try await tables.play(card: card, player: player, in: database)
2021-12-22 22:11:37 +01:00
}
2022-01-24 17:15:11 +01:00
func disconnectAllSockets() {
tables.disconnectAllSockets()
}
2021-12-22 22:11:37 +01:00
}