// // PatreonAPI.swift // AltStore // // Created by Riley Testut on 8/20/19. // Copyright © 2019 Riley Testut. All rights reserved. // import Foundation import AuthenticationServices import CoreData private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2" private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt" private let campaignID = "2863968" extension PatreonAPI { enum Error: LocalizedError { case unknown case notAuthenticated case invalidAccessToken var errorDescription: String? { switch self { case .unknown: return NSLocalizedString("An unknown error occurred.", comment: "") case .notAuthenticated: return NSLocalizedString("No connected Patreon account.", comment: "") case .invalidAccessToken: return NSLocalizedString("Invalid access token.", comment: "") } } } enum AuthorizationType { case none case user case creator } enum AnyResponse: Decodable { case tier(TierResponse) case benefit(BenefitResponse) enum CodingKeys: String, CodingKey { case type } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) switch type { case "tier": let tier = try TierResponse(from: decoder) self = .tier(tier) case "benefit": let benefit = try BenefitResponse(from: decoder) self = .benefit(benefit) default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unrecognized Patreon response type.") } } } } class PatreonAPI: NSObject { static let shared = PatreonAPI() var isAuthenticated: Bool { return Keychain.shared.patreonAccessToken != nil } private var authenticationSession: ASWebAuthenticationSession? private let session = URLSession(configuration: .ephemeral) private let baseURL = URL(string: "https://www.patreon.com/")! private override init() { super.init() } } extension PatreonAPI { func authenticate(completion: @escaping (Result) -> Void) { var components = URLComponents(string: "/oauth2/authorize")! components.queryItems = [URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "client_id", value: clientID), URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore")] let requestURL = components.url(relativeTo: self.baseURL)! self.authenticationSession = ASWebAuthenticationSession(url: requestURL, callbackURLScheme: "altstore") { (callbackURL, error) in do { let callbackURL = try Result(callbackURL, error).get() guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }), let code = codeQueryItem.value else { throw Error.unknown } self.fetchAccessToken(oauthCode: code) { (result) in switch result { case .failure(let error): completion(.failure(error)) case .success((let accessToken, let refreshToken)): Keychain.shared.patreonAccessToken = accessToken Keychain.shared.patreonRefreshToken = refreshToken self.fetchAccount(completion: completion) } } } catch { completion(.failure(error)) } } if #available(iOS 13.0, *) { self.authenticationSession?.presentationContextProvider = self } self.authenticationSession?.start() } func fetchAccount(completion: @escaping (Result) -> Void) { var components = URLComponents(string: "/api/oauth2/v2/identity")! components.queryItems = [URLQueryItem(name: "include", value: "memberships"), URLQueryItem(name: "fields[user]", value: "first_name,full_name"), URLQueryItem(name: "fields[member]", value: "full_name,patron_status")] let requestURL = components.url(relativeTo: self.baseURL)! let request = URLRequest(url: requestURL) self.send(request, authorizationType: .user) { (result: Result) in switch result { case .failure(Error.notAuthenticated): self.signOut() { (result) in completion(.failure(Error.notAuthenticated)) } case .failure(let error): completion(.failure(error)) case .success(let response): DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in let account = PatreonAccount(response: response, context: context) completion(.success(account)) } } } } func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void) { var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(campaignID)/members")! components.queryItems = [URLQueryItem(name: "include", value: "currently_entitled_tiers,currently_entitled_tiers.benefits"), URLQueryItem(name: "fields[tier]", value: "title"), URLQueryItem(name: "fields[member]", value: "full_name,patron_status"), URLQueryItem(name: "page[size]", value: "1000")] let requestURL = components.url(relativeTo: self.baseURL)! struct Response: Decodable { var data: [PatronResponse] var included: [AnyResponse] var links: [String: URL]? } var allPatrons = [Patron]() func fetchPatrons(url: URL) { let request = URLRequest(url: url) self.send(request, authorizationType: .creator) { (result: Result) in switch result { case .failure(let error): completion(.failure(error)) case .success(let response): let tiers = response.included.compactMap { (response) -> Tier? in switch response { case .tier(let tierResponse): return Tier(response: tierResponse) case .benefit: return nil } } let tiersByIdentifier = Dictionary(tiers.map { ($0.identifier, $0) }, uniquingKeysWith: { (a, b) in return a }) let patrons = response.data.map { (response) -> Patron in let patron = Patron(response: response) for tierID in response.relationships?.currently_entitled_tiers.data ?? [] { guard let tier = tiersByIdentifier[tierID.id] else { continue } patron.benefits.formUnion(tier.benefits) } return patron }.filter { $0.benefits.contains(where: { $0.type == .credits }) } allPatrons.append(contentsOf: patrons) if let nextURL = response.links?["next"] { fetchPatrons(url: nextURL) } else { completion(.success(allPatrons)) } } } } fetchPatrons(url: requestURL) } func signOut(completion: @escaping (Result) -> Void) { DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in do { let accounts = PatreonAccount.all(in: context, requestProperties: [\FetchRequest.returnsObjectsAsFaults: true]) accounts.forEach(context.delete(_:)) self.deactivateBetaApps(in: context) try context.save() Keychain.shared.patreonAccessToken = nil Keychain.shared.patreonRefreshToken = nil completion(.success(())) } catch { completion(.failure(error)) } } } func refreshPatreonAccount() { guard PatreonAPI.shared.isAuthenticated else { return } PatreonAPI.shared.fetchAccount { (result: Result) in do { let account = try result.get() if let context = account.managedObjectContext, !account.isPatron { // Deactivate all beta apps now that we're no longer a patron. self.deactivateBetaApps(in: context) } try account.managedObjectContext?.save() } catch { print("Failed to fetch Patreon account.", error) } } } } private extension PatreonAPI { func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void) { let encodedRedirectURI = ("https://rileytestut.com/patreon/altstore" as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)! let encodedOauthCode = (oauthCode as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)! let body = "code=\(encodedOauthCode)&grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(encodedRedirectURI)" let requestURL = URL(string: "/api/oauth2/token", relativeTo: self.baseURL)! var request = URLRequest(url: requestURL) request.httpMethod = "POST" request.httpBody = body.data(using: .utf8) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") struct Response: Decodable { var access_token: String var refresh_token: String } self.send(request, authorizationType: .none) { (result: Result) in switch result { case .failure(let error): completion(.failure(error)) case .success(let response): completion(.success((response.access_token, response.refresh_token))) } } } func refreshAccessToken(completion: @escaping (Result) -> Void) { guard let refreshToken = Keychain.shared.patreonRefreshToken else { return } var components = URLComponents(string: "/api/oauth2/token")! components.queryItems = [URLQueryItem(name: "grant_type", value: "refresh_token"), URLQueryItem(name: "refresh_token", value: refreshToken), URLQueryItem(name: "client_id", value: clientID), URLQueryItem(name: "client_secret", value: clientSecret)] let requestURL = components.url(relativeTo: self.baseURL)! var request = URLRequest(url: requestURL) request.httpMethod = "POST" struct Response: Decodable { var access_token: String var refresh_token: String } self.send(request, authorizationType: .none) { (result: Result) in switch result { case .failure(let error): completion(.failure(error)) case .success(let response): Keychain.shared.patreonAccessToken = response.access_token Keychain.shared.patreonRefreshToken = response.refresh_token completion(.success(())) } } } func send(_ request: URLRequest, authorizationType: AuthorizationType, completion: @escaping (Result) -> Void) { var request = request switch authorizationType { case .none: break case .creator: guard let creatorAccessToken = Keychain.shared.patreonCreatorAccessToken else { return completion(.failure(Error.invalidAccessToken)) } request.setValue("Bearer " + creatorAccessToken, forHTTPHeaderField: "Authorization") case .user: guard let accessToken = Keychain.shared.patreonAccessToken else { return completion(.failure(Error.notAuthenticated)) } request.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization") } let task = self.session.dataTask(with: request) { (data, response, error) in do { let data = try Result(data, error).get() if let response = response as? HTTPURLResponse, response.statusCode == 401 { switch authorizationType { case .creator: completion(.failure(Error.invalidAccessToken)) case .none: completion(.failure(Error.notAuthenticated)) case .user: self.refreshAccessToken() { (result) in switch result { case .failure(let error): completion(.failure(error)) case .success: self.send(request, authorizationType: authorizationType, completion: completion) } } } return } let response = try JSONDecoder().decode(ResponseType.self, from: data) completion(.success(response)) } catch let error { completion(.failure(error)) } } task.resume() } func deactivateBetaApps(in context: NSManagedObjectContext) { let predicate = NSPredicate(format: "%K != %@ AND %K != nil AND %K == YES", #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID, #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta)) let installedApps = InstalledApp.all(satisfying: predicate, in: context) installedApps.forEach { $0.isActive = false } } } @available(iOS 13.0, *) extension PatreonAPI: ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { return UIApplication.shared.keyWindow ?? UIWindow() } }