six-card-golf/Shared/Game/GolfAPI.swift
Lukas Schaefer e609ea881f
Allow deleting account
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-01 20:35:00 -04:00

301 lines
11 KiB
Swift

import Foundation
enum GolfAPIError: LocalizedError {
case notLoggedIn
case http(Int, String)
case decoding
case noGameID
case permanentTokenFailed
var errorDescription: String? {
switch self {
case .notLoggedIn: return "Log in to play."
case .http(let code, let body): return "Server error (\(code)): \(body)"
case .decoding: return "Could not read server response."
case .noGameID: return "Server did not return a game id."
case .permanentTokenFailed: return "Could not create a permanent login key."
}
}
}
struct GolfAPISnapshot: Equatable {
struct Rules: Equatable {
var cardNumber: Int
var pointsToEnd: Int
}
struct Card: Equatable {
var card: Int
var cardPlacement: Int
}
struct Player: Equatable, Identifiable {
var id: String { user }
var user: String
var points: Int
var orderID: Int
var lastMode: String
var multiplier: Int
var cards: [Card]
var currentGamePoints: Int
}
var rules: Rules
var currentPlayer: Int
var discard: [Int]
var deckSize: Int
var players: [Player]
var action: String
var discardTop: Int? { discard.last }
func player(named username: String) -> Player? {
players.first { $0.user == username }
}
}
actor GolfAPI {
static let shared = GolfAPI()
static let baseURL = URL(string: "https://www.lschaefer.xyz")!
private static let sessionKeyAccount = "sessionKey"
private static let usernameAccount = "username"
private var sessionKey: String? {
get { KeychainStore.string(forKey: Self.sessionKeyAccount) }
set { KeychainStore.set(newValue, forKey: Self.sessionKeyAccount) }
}
private(set) var username: String? {
get { KeychainStore.string(forKey: Self.usernameAccount) }
set { KeychainStore.set(newValue, forKey: Self.usernameAccount) }
}
var isLoggedIn: Bool { sessionKey?.isEmpty == false }
func currentUsername() -> String? { username }
func setSessionKey(_ key: String, username: String) {
sessionKey = key
self.username = username
}
/// Exchanges the short-lived web login cookie for a non-expiring API key (`expire = 0` on `/api/key.php`).
func requestPermanentToken() async throws -> String {
let data = try await post([.init(name: "create", value: "0")], path: "api/key.php")
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let cookie = obj["cookie"] as? String,
!cookie.isEmpty
else { throw GolfAPIError.permanentTokenFailed }
return cookie
}
func logout() {
sessionKey = nil
username = nil
}
func deleteAccount(username: String) async throws {
_ = try await post([
.init(name: "type", value: "delete"),
.init(name: "user", value: username)
], path: "api/user.php")
logout()
}
func createGame(
name: String,
pointsToEnd: Int,
playersToStart: Int,
bots: Int,
decks: Int,
password: String
) async throws -> Int {
let data = try await post([
.init(name: "create", value: name),
.init(name: "cardNumber", value: "6"),
.init(name: "flipNumber", value: "2"),
.init(name: "playersToStart", value: "\(playersToStart)"),
.init(name: "bots", value: "\(max(0, bots))"),
.init(name: "multiplierForFlip", value: "2"),
.init(name: "pointsToEnd", value: "\(pointsToEnd)"),
.init(name: "decks", value: "\(max(1, decks))"),
.init(name: "skipTime", value: "0"),
.init(name: "skipTurns", value: "0"),
.init(name: "resetPoints", value: "0"),
.init(name: "password", value: password)
])
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw GolfAPIError.noGameID
}
if let id = obj["id"] as? Int { return id }
if let id = obj["id"] as? NSNumber { return id.intValue }
throw GolfAPIError.noGameID
}
func join(gameID: Int, password: String?) async throws {
do {
var items = [URLQueryItem(name: "join", value: "\(gameID)")]
if let password, !password.isEmpty {
items.append(.init(name: "password", value: password))
}
_ = try await post(items)
} catch {
if case GolfAPIError.http(let code, let body) = error,
code == 409 || body.lowercased().contains("already joined") {
return
}
if error.localizedDescription.lowercased().contains("already joined") { return }
throw error
}
}
func forceUpdate(gameID: Int) async throws {
_ = try await get(queryItems: [.init(name: "forceUpdate", value: "\(gameID)")])
}
func update(gameID: Int) async throws -> GolfAPISnapshot? {
let (data, code) = try await getRaw(queryItems: [.init(name: "update", value: "\(gameID)")])
if code == 304 { return nil }
let text = String(data: data, encoding: .utf8) ?? ""
if text == "[]" || text.isEmpty || text == "No change" { return nil }
return try decodeSnapshot(data)
}
func swap(gameID: Int, slot: Int, source: SwapSource) async throws {
_ = try await post([
.init(name: "swap", value: "\(slot + 1)"),
.init(name: "swap2", value: source == .deck ? "deck" : "discard"),
.init(name: "game", value: "\(gameID)")
])
}
private func requireKey() throws -> String {
guard let key = sessionKey, !key.isEmpty else { throw GolfAPIError.notLoggedIn }
return key
}
private func post(_ items: [URLQueryItem], path: String = "api/golf.php") async throws -> Data {
let key = try requireKey()
var components = URLComponents()
components.queryItems = items
var request = URLRequest(url: Self.baseURL.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue(key, forHTTPHeaderField: "Authorization")
request.httpBody = components.query?.data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
let text = String(data: data, encoding: .utf8) ?? ""
guard (200..<300).contains(code) else { throw GolfAPIError.http(code, text) }
return data
}
private func get(queryItems: [URLQueryItem]) async throws -> Data {
let (data, code) = try await getRaw(queryItems: queryItems)
guard (200..<300).contains(code) else {
throw GolfAPIError.http(code, String(data: data, encoding: .utf8) ?? "")
}
return data
}
private func getRaw(queryItems: [URLQueryItem]) async throws -> (Data, Int) {
let key = try requireKey()
var components = URLComponents(
url: Self.baseURL.appendingPathComponent("api/golf.php"),
resolvingAgainstBaseURL: false
)!
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.setValue(key, forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
return (data, (response as? HTTPURLResponse)?.statusCode ?? 0)
}
private func decodeSnapshot(_ data: Data) throws -> GolfAPISnapshot {
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let rulesObj = root["rules"] as? [String: Any]
else { throw GolfAPIError.decoding }
let discard = (root["discard"] as? [Any])?.compactMap { intValue(optional: $0) } ?? []
let playersJSON = root["players"] as? [[String: Any]] ?? []
let players: [GolfAPISnapshot.Player] = playersJSON.map { p in
let cards = (p["cards"] as? [[String: Any]] ?? []).map {
GolfAPISnapshot.Card(
card: intValue($0["card"]),
cardPlacement: intValue($0["cardPlacement"])
)
}
return GolfAPISnapshot.Player(
user: p["user"] as? String ?? "",
points: intValue(p["points"]),
orderID: intValue(p["orderID"]),
lastMode: p["lastMode"] as? String ?? "",
multiplier: intValue(p["multiplier"]),
cards: cards,
currentGamePoints: intValue(p["currentGamePoints"])
)
}
return GolfAPISnapshot(
rules: GolfAPISnapshot.Rules(
cardNumber: intValue(rulesObj["cardNumber"]),
pointsToEnd: intValue(rulesObj["pointsToEnd"])
),
currentPlayer: intValue(root["currentPlayer"]),
discard: discard,
deckSize: intValue(root["deckSize"]),
players: players,
action: root["action"] as? String ?? ""
)
}
private func intValue(_ any: Any?) -> Int {
intValue(optional: any) ?? 0
}
private func intValue(optional any: Any?) -> Int? {
if let i = any as? Int { return i }
if let n = any as? NSNumber { return n.intValue }
if let s = any as? String { return Int(s) }
return nil
}
}
struct MessageGameRef: Equatable {
var gameID: Int
var password: String?
}
enum MessageGameID {
static let base = URL(string: "https://www.lschaefer.xyz/golf/game.php")!
static func url(gameID: Int, password: String?) -> URL {
var components = URLComponents(url: base, resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "game", value: "\(gameID)")]
if let password, !password.isEmpty {
components.fragment = password
}
return components.url!
}
static func parse(from url: URL?) -> MessageGameRef? {
guard let url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let game = components.queryItems?.first(where: { $0.name == "game" })?.value,
let gameID = Int(game)
else { return nil }
let password = components.fragment.flatMap { $0.isEmpty ? nil : $0 }
return MessageGameRef(gameID: gameID, password: password)
}
/// Alphanumeric only safe for URL fragments and the server's SQL-quoted password field.
static func randomPassword(length: Int = 12) -> String {
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
var result = ""
result.reserveCapacity(length)
for _ in 0..<length {
result.append(alphabet[Int.random(in: 0..<alphabet.count)])
}
return result
}
}