From fc1f2088d378ab3e439b7d614f88d19d1c5d8caa Mon Sep 17 00:00:00 2001 From: Joseph Mattello Date: Sun, 23 Oct 2022 03:38:27 -0400 Subject: [PATCH] Add some demo code Signed-off-by: Joseph Mattiello Signed-off-by: Joseph Mattello patreon: move to pvlib Signed-off-by: Joseph Mattello --- PVLibrary/PVLibrary/Keychain/Keychain.swift | 91 ++++ PVLibrary/PVLibrary/Patreon/Benefit.swift | 38 ++ PVLibrary/PVLibrary/Patreon/Campaign.swift | 29 ++ PVLibrary/PVLibrary/Patreon/PatreonAPI.swift | 434 ++++++++++++++++++ .../PVLibrary/Patreon/PatreonAccount.swift | 55 +++ PVLibrary/PVLibrary/Patreon/Patron.swift | 78 ++++ PVLibrary/PVLibrary/Patreon/Tier.swift | 52 +++ 7 files changed, 777 insertions(+) create mode 100755 PVLibrary/PVLibrary/Keychain/Keychain.swift create mode 100755 PVLibrary/PVLibrary/Patreon/Benefit.swift create mode 100755 PVLibrary/PVLibrary/Patreon/Campaign.swift create mode 100755 PVLibrary/PVLibrary/Patreon/PatreonAPI.swift create mode 100755 PVLibrary/PVLibrary/Patreon/PatreonAccount.swift create mode 100755 PVLibrary/PVLibrary/Patreon/Patron.swift create mode 100755 PVLibrary/PVLibrary/Patreon/Tier.swift diff --git a/PVLibrary/PVLibrary/Keychain/Keychain.swift b/PVLibrary/PVLibrary/Keychain/Keychain.swift new file mode 100755 index 0000000000..9be93d109e --- /dev/null +++ b/PVLibrary/PVLibrary/Keychain/Keychain.swift @@ -0,0 +1,91 @@ +// +// Keychain.swift +// AltStore +// +// Created by Riley Testut on 6/4/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation +import KeychainAccess + +//import AltSign + +@propertyWrapper +public struct KeychainItem +{ + public let key: String + + public var wrappedValue: Value? { + get { + switch Value.self + { + case is Data.Type: return try? Keychain.shared.keychain.getData(self.key) as? Value + case is String.Type: return try? Keychain.shared.keychain.getString(self.key) as? Value + default: return nil + } + } + set { + switch Value.self + { + case is Data.Type: Keychain.shared.keychain[data: self.key] = newValue as? Data + case is String.Type: Keychain.shared.keychain[self.key] = newValue as? String + default: break + } + } + } + + public init(key: String) + { + self.key = key + } +} + +public class Keychain +{ + public static let shared = Keychain() + + fileprivate let keychain = KeychainAccess.Keychain(service: "org.provenance-emu.provenance").accessibility(.afterFirstUnlock).synchronizable(true) + +// @KeychainItem(key: "appleIDEmailAddress") +// public var appleIDEmailAddress: String? +// +// @KeychainItem(key: "appleIDPassword") +// public var appleIDPassword: String? +// +// @KeychainItem(key: "signingCertificatePrivateKey") +// public var signingCertificatePrivateKey: Data? +// +// @KeychainItem(key: "signingCertificateSerialNumber") +// public var signingCertificateSerialNumber: String? +// +// @KeychainItem(key: "signingCertificate") +// public var signingCertificate: Data? +// +// @KeychainItem(key: "signingCertificatePassword") +// public var signingCertificatePassword: String? + + @KeychainItem(key: "patreonAccessToken") + public var patreonAccessToken: String? + + @KeychainItem(key: "patreonRefreshToken") + public var patreonRefreshToken: String? + + @KeychainItem(key: "patreonCreatorAccessToken") + public var patreonCreatorAccessToken: String? + + @KeychainItem(key: "patreonAccountID") + public var patreonAccountID: String? + + private init() + { + } + + public func reset() + { +// self.appleIDEmailAddress = nil +// self.appleIDPassword = nil +// self.signingCertificatePrivateKey = nil +// self.signingCertificateSerialNumber = nil + } +} diff --git a/PVLibrary/PVLibrary/Patreon/Benefit.swift b/PVLibrary/PVLibrary/Patreon/Benefit.swift new file mode 100755 index 0000000000..e06ff46b09 --- /dev/null +++ b/PVLibrary/PVLibrary/Patreon/Benefit.swift @@ -0,0 +1,38 @@ +// +// Benefit.swift +// AltStore +// +// Created by Riley Testut on 8/21/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation + +public enum PVPatreonBenefitType: String { + case betaAccess = "7585304" + case credit = "8490206" +} + +@available(iOS 12.0, tvOS 12.0, *) +extension PatreonAPI +{ + struct BenefitResponse: Decodable + { + var id: String + } +} + +@available(iOS 12.0, tvOS 12.0, *) +public struct Benefit: Hashable +{ + public var type: PVPatreonBenefitType + + init?(response: PatreonAPI.BenefitResponse) + { + guard let type = PVPatreonBenefitType(rawValue: response.id) else { + ELOG("Unknown benefit id \(response.id)") + return nil + } + self.type = type + } +} diff --git a/PVLibrary/PVLibrary/Patreon/Campaign.swift b/PVLibrary/PVLibrary/Patreon/Campaign.swift new file mode 100755 index 0000000000..7b6a23b745 --- /dev/null +++ b/PVLibrary/PVLibrary/Patreon/Campaign.swift @@ -0,0 +1,29 @@ +// +// Campaign.swift +// AltStore +// +// Created by Riley Testut on 8/21/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation + +@available(iOS 12.0, tvOS 12.0, *) +extension PatreonAPI +{ + struct CampaignResponse: Decodable + { + var id: String + } +} + +@available(iOS 12.0, tvOS 12.0, *) +public struct Campaign +{ + public var identifier: String + + init(response: PatreonAPI.CampaignResponse) + { + self.identifier = response.id + } +} diff --git a/PVLibrary/PVLibrary/Patreon/PatreonAPI.swift b/PVLibrary/PVLibrary/Patreon/PatreonAPI.swift new file mode 100755 index 0000000000..908c84150a --- /dev/null +++ b/PVLibrary/PVLibrary/Patreon/PatreonAPI.swift @@ -0,0 +1,434 @@ +// +// 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 = "nSNDsv4K_SHF_kLfNgjTi52cU2bTuwunxu9g6j61WtQxoaGEHy1aNAZydM4VcMiz" +private let clientSecret = "QkHx9MirO0QYvVcJzrsoRU5IO9qusihvbwaXVQRlUohnS631CQKunSkDDVAnJbkZ" + +private let campaignID = "2198356" + +@available(iOS 12.0, tvOS 12.0, *) +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.") + } + } + } +} + +@available(iOS 12.0, tvOS 12.0, *) +public class PatreonAPI: NSObject +{ + public static let shared = PatreonAPI() + + public 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() + } +} + +@available(iOS 12.0, tvOS 12.0, *) +public 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 { (result) in + switch result + { + case .success(let account): Keychain.shared.patreonAccountID = account.identifier + case .failure: break + } + + completion(result) + } + + } + } + } + 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 + { + if let account = DatabaseManager.shared.patreonAccount(in: context) { + context.delete(account) + } + + self.deactivateBetaApps(in: context) + + try context.save() + + Keychain.shared.patreonAccessToken = nil + Keychain.shared.patreonRefreshToken = nil + Keychain.shared.patreonAccountID = 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) + } + } + } +} + +@available(iOS 12.0, tvOS 12.0, *) +private extension PatreonAPI +{ + func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void) + { + let encodedRedirectURI = ("https://provenance-emu.com/patreon_redirect" 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 +{ + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor + { + return UIApplication.shared.keyWindow ?? UIWindow() + } +} diff --git a/PVLibrary/PVLibrary/Patreon/PatreonAccount.swift b/PVLibrary/PVLibrary/Patreon/PatreonAccount.swift new file mode 100755 index 0000000000..b46a65c453 --- /dev/null +++ b/PVLibrary/PVLibrary/Patreon/PatreonAccount.swift @@ -0,0 +1,55 @@ +// +// PatreonAccount.swift +// AltStore +// +// Created by Riley Testut on 8/20/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import CoreData + +@available(iOS 12.0, tvOS 12.0, *) +extension PatreonAPI +{ + struct AccountResponse: Decodable + { + struct Data: Decodable + { + struct Attributes: Decodable + { + var first_name: String? + var full_name: String + } + + var id: String + var attributes: Attributes + } + + var data: Data + var included: [PatronResponse]? + } +} + +@available(iOS 12.0, tvOS 12.0, *) +public struct PatreonAccount: Codable +{ + public let identifier: String + + public let name: String + public let firstName: String? + + public let isPatron: Bool + + init(response: PatreonAPI.AccountResponse) { + self.identifier = response.data.id + self.name = response.data.attributes.full_name + self.firstName = response.data.attributes.first_name + + if let patronResponse = response.included?.first { + let patron = Patron(response: patronResponse) + self.isPatron = (patron.status == .active) + } else { + self.isPatron = false + } + } +} diff --git a/PVLibrary/PVLibrary/Patreon/Patron.swift b/PVLibrary/PVLibrary/Patreon/Patron.swift new file mode 100755 index 0000000000..d716b86c52 --- /dev/null +++ b/PVLibrary/PVLibrary/Patreon/Patron.swift @@ -0,0 +1,78 @@ +// +// Patron.swift +// AltStore +// +// Created by Riley Testut on 8/21/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation + +extension PatreonAPI +{ + struct PatronResponse: Decodable + { + struct Attributes: Decodable + { + var full_name: String + var patron_status: String? + } + + struct Relationships: Decodable + { + struct Tiers: Decodable + { + struct TierID: Decodable + { + var id: String + var type: String + } + + var data: [TierID] + } + + var currently_entitled_tiers: Tiers + } + + var id: String + var attributes: Attributes + + var relationships: Relationships? + } +} + +extension Patron +{ + public enum Status: String, Decodable + { + case active = "active_patron" + case declined = "declined_patron" + case former = "former_patron" + case unknown = "unknown" + } +} + +public class Patron +{ + public var name: String + public var identifier: String + + public var status: Status + + public var benefits: Set = [] + + init(response: PatreonAPI.PatronResponse) + { + self.name = response.attributes.full_name + self.identifier = response.id + + if let status = response.attributes.patron_status + { + self.status = Status(rawValue: status) ?? .unknown + } + else + { + self.status = .unknown + } + } +} diff --git a/PVLibrary/PVLibrary/Patreon/Tier.swift b/PVLibrary/PVLibrary/Patreon/Tier.swift new file mode 100755 index 0000000000..422f2d3989 --- /dev/null +++ b/PVLibrary/PVLibrary/Patreon/Tier.swift @@ -0,0 +1,52 @@ +// +// Tier.swift +// AltStore +// +// Created by Riley Testut on 8/21/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import Foundation + +@available(iOS 12.0, tvOS 12.0, *) +extension PatreonAPI +{ + struct TierResponse: Decodable + { + struct Attributes: Decodable + { + var title: String + } + + struct Relationships: Decodable + { + struct Benefits: Decodable + { + var data: [BenefitResponse] + } + + var benefits: Benefits + } + + var id: String + var attributes: Attributes + + var relationships: Relationships + } +} + +@available(iOS 12.0, tvOS 12.0, *) +public struct Tier +{ + public var name: String + public var identifier: String + + public var benefits: [Benefit] = [] + + init(response: PatreonAPI.TierResponse) + { + self.name = response.attributes.title + self.identifier = response.id + self.benefits = response.relationships.benefits.data.map(Benefit.init(response:)) + } +}