import Foundation import MediaKit /// Die Anmeldung an einem Flaro-Server. /// /// Getrennt vom Backend, weil sie vorher stattfindet: hier gibt es noch /// keinen Zugang und nichts, was `MediaBackend` versprechen könnte. public enum FlaroSignIn { /// Was der Server über sich sagt, bevor sich jemand anmeldet. public struct Greeting: Sendable, Equatable { public let serverName: String public let version: String? /// Ob der Server überhaupt schon eingerichtet ist. /// /// **Ein eigener Fall, keine Fehlermeldung.** Wer einen frischen /// Server anspricht, hat kein Konto — und „Benutzername oder /// Passwort stimmt nicht" wäre dann die unbrauchbarste aller /// Antworten. public let needsSetup: Bool /// Der Pflichthinweis auf die Metadatenquelle. public let attributions: [MetadataAttribution] } public struct MetadataAttribution: Sendable, Equatable { public let text: String public let logoURL: URL? public let url: URL? } /// Fragt einen Server, wer er ist — ohne Anmeldung. /// /// Das ist zugleich die Prüfung, ob unter der eingegebenen Adresse /// überhaupt ein Flaro steht. Ein Jellyfin antwortet hier mit 404, ein /// Router mit seiner Anmeldeseite, und beides fällt beim Lesen auf. public static func greet( baseURL: URL, identity: FlaroIdentity, transport: any FlaroTransport = URLSession.shared ) async throws(MediaBackendError) -> Greeting { let client = FlaroClient( baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil) let auskunft = try await client.send( FlaroRequest(path: "/api/v1/system/info"), as: Wire.SystemInfo.self) return Greeting( serverName: auskunft.serverName ?? baseURL.host() ?? "Flaro", version: auskunft.version, needsSetup: auskunft.needsSetup, attributions: (auskunft.attributions ?? []).map { MetadataAttribution( text: $0.text, logoURL: URL(string: $0.logoUrl), url: URL(string: $0.url)) }) } /// Meldet an und liefert den Zugang samt fertigem Backend. public static func signIn( baseURL: URL, userName: String, password: String, identity: FlaroIdentity, transport: any FlaroTransport = URLSession.shared, cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared ) async throws(MediaBackendError) -> (token: String, account: FlaroAccount, client: FlaroClient) { let gruss = try await greet(baseURL: baseURL, identity: identity, transport: transport) let client = FlaroClient( baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar) let antwort = try await client.send( .json(.post, "/api/v1/auth/login", Wire.LoginRequest( userName: userName, password: password, deviceName: identity.deviceName, deviceId: identity.deviceID)), as: Wire.LoginResponse.self) // **Erst jetzt das Token setzen.** Vorher hätte die Anmeldung ihre // eigene Kopfzeile mitgeschickt — harmlos, aber auch das Cookie wäre // dann schon gesetzt, bevor feststeht, dass es gilt. await client.setToken(antwort.token) return (antwort.token, FlaroAccount( baseURL: baseURL, serverName: gruss.serverName, userID: antwort.user.id, userName: antwort.user.userName, isAdministrator: antwort.user.isAdministrator), client) } /// Nimmt ein gespeichertes Token wieder auf. /// /// **Ohne Passwort und ohne neue Anmeldung.** Der Server führt das Gerät /// über das Token; ein zweiter Anmeldevorgang legte ein zweites Gerät an, /// und in der Liste stünde dann bei jedem Start eines mehr. public static func restore( baseURL: URL, token: String, identity: FlaroIdentity, transport: any FlaroTransport = URLSession.shared, cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared ) async throws(MediaBackendError) -> (account: FlaroAccount, client: FlaroClient) { let client = FlaroClient( baseURL: baseURL, identity: identity, token: token, transport: transport, cookieJar: cookieJar) let wer = try await client.send(FlaroRequest(path: "/api/v1/auth/me"), as: Wire.User.self) let gruss = try? await greet(baseURL: baseURL, identity: identity, transport: transport) return (FlaroAccount( baseURL: baseURL, serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro", userID: wer.id, userName: wer.userName, isAdministrator: wer.isAdministrator), client) } // MARK: - Der Weg über einen Code /// Ein angeforderter Code samt Abholschein. public struct PendingCode: Sendable, Equatable { /// Die sechs Ziffern, die auf dem Fernseher stehen. public let code: String /// **Der Abholschein, und er ist das eigentliche Geheimnis.** /// /// Abgeholt wird das Token hiermit, nicht mit dem Code. Täte es der /// Code allein, liessen sich eine Million Möglichkeiten durchprobieren /// — und die sechs Ziffern stehen zum Mitlesen auf einem Bildschirm /// im Wohnzimmer. public let handle: String public let expiresAt: Date } public enum CodeState: Sendable, Equatable { case pending case approved(token: String, account: FlaroAccount) case expired } /// Fordert einen Code an. Der Fernseher zeigt ihn, das Telefon bestätigt. public static func requestCode( baseURL: URL, identity: FlaroIdentity, transport: any FlaroTransport = URLSession.shared ) async throws(MediaBackendError) -> PendingCode { let client = FlaroClient( baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil) let antwort = try await client.send( .json(.post, "/api/v1/auth/code", Wire.CodeRequest( deviceName: identity.deviceName, deviceId: identity.deviceID)), as: Wire.CodeResponse.self) return PendingCode( code: antwort.code, handle: antwort.handle, expiresAt: antwort.expiresAt) } /// Fragt nach, ob jemand den Code inzwischen bestätigt hat. public static func pollCode( baseURL: URL, pending: PendingCode, identity: FlaroIdentity, transport: any FlaroTransport = URLSession.shared, cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared ) async throws(MediaBackendError) -> (state: CodeState, client: FlaroClient?) { let client = FlaroClient( baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar) // **Mit dem Abholschein, nicht mit dem Code.** Siehe `PendingCode`. let zustand = try await client.send( FlaroRequest(path: "/api/v1/auth/code/\(pending.handle)"), as: Wire.CodeState.self) switch zustand.state { case "approved": guard let anmeldung = zustand.login else { return (.expired, nil) } await client.setToken(anmeldung.token) let gruss = try? await greet( baseURL: baseURL, identity: identity, transport: transport) return (.approved(token: anmeldung.token, account: FlaroAccount( baseURL: baseURL, serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro", userID: anmeldung.user.id, userName: anmeldung.user.userName, isAdministrator: anmeldung.user.isAdministrator)), client) case "expired": return (.expired, nil) default: return (.pending, nil) } } /// Meldet dieses Gerät ab und widerruft sein Token. public static func signOut(client: FlaroClient) async { _ = try? await client.send(FlaroRequest(method: .post, path: "/api/v1/auth/logout")) await client.setToken(nil) } }