// // Client.swift // Lockdown // // Created by Johnny Lin on 7/31/19. // Copyright © 2019 Confirmed Inc. All rights reserved. // import Foundation import PromiseKit import SwiftyStoreKit import CocoaLumberjackSwift let kApiCodeNoError = 0 let kApiCodeEmailNotConfirmed = 1 let kApiCodeIncorrectLogin = 2 let kApiCodeRequestFieldValidationError = 3 let kApiCodeNoActiveSubscription = 6 let kApiCodeNoSubscriptionInReceipt = 9 let kApiCodeMobileSubscriptionOnly = 38 let kApiCodeEmailAlreadyUsed = 40 let kApiCodeReceiptAlreadyUsed = 48 let kApiCodeInvalidAuth = 401 let kApiCodeTooManyRequests = 999 let kApiCodeSandboxReceiptNotAllowed = 9925 let kApiCodeUnknownError = 99999 let kApiCodeNegativeError = -1 class Client { // MARK: - CLIENT CALLS static func signIn(forceRefresh: Bool = false) throws -> Promise { DDLogInfo("API CALL: signIn") URLCache.shared.removeAllCachedResponses() clearCookies() return getReceipt(forceRefresh: forceRefresh) .then { receipt -> Promise<(data: Data, response: URLResponse)> in let parameters:[String : Any] = [ "authtype": "ios", "authreceipt": receipt, "lockdown": true ] return URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/signin", parameters: parameters)) } .map { data, response -> SignIn in try self.validateApiResponse(data: data, response: response) let resp = response as! HTTPURLResponse // already validated the type in validateApiResponse DDLogInfo("Got signin response with headers: \(resp.allHeaderFields)") if (hasValidCookie()) { return try JSONDecoder().decode(SignIn.self, from: data) } else { throw "No valid cookie received and/or set when trying to sign in" } } } static func signInWithEmail(email: String, password: String) throws -> Promise { DDLogInfo("API CALL: test signIn with email") URLCache.shared.removeAllCachedResponses() clearCookies() return firstly { () -> Promise<(data: Data, response: URLResponse)> in let parameters:[String : Any] = [ "email" : email, "password" : password, "lockdown": true ] return URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/signin", parameters: parameters)) } .map { data, response -> SignIn in try self.validateApiResponse(data: data, response: response) let resp = response as! HTTPURLResponse // already validated the type in validateApiResponse DDLogInfo("Got signin (with email) response with headers: \(resp.allHeaderFields)") return try JSONDecoder().decode(SignIn.self, from: data) } } static func resendConfirmCode(email: String) throws -> Promise { DDLogInfo("API CALL: resendConfirmCode") return firstly { () -> Promise<(data: Data, response: URLResponse)> in let parameters:[String : Any] = [ "email" : email, "lockdown": true ] return URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/resend-confirm-code", parameters: parameters)) } .map { data, response -> Bool in if let httpResponse = response as? HTTPURLResponse { DDLogInfo("API RESULT: resend-confirm-code: \(httpResponse.statusCode)") if httpResponse.statusCode < 400 { return true } return false } DDLogInfo("API RESULT: error - resend-confirm-code: not HTTPURLResponse") return false } } static func subscriptionEvent(forceRefresh: Bool = false) throws -> Promise { DDLogInfo("API CALL: subscription-event") return getReceipt(forceRefresh: forceRefresh) .then { receipt -> Promise<(data: Data, response: URLResponse)> in let parameters:[String : Any] = [ "authtype": "ios", "authreceipt": receipt, "lockdown": true ] return URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/subscription-event", parameters: parameters)) } .map { data, response -> SubscriptionEvent in try self.validateApiResponse(data: data, response: response) let subscriptionEvent = try JSONDecoder().decode(SubscriptionEvent.self, from: data) DDLogInfo("API RESULT: subscriptionEvent: \(subscriptionEvent)") return subscriptionEvent } .recover { error -> Promise in DDLogInfo("Recovering from subscription-event error: \(error)") return .value(SubscriptionEvent(message: "Recovery")) } } static func activeSubscriptions() throws -> Promise<[Subscription]> { DDLogInfo("API CALL: active-subscriptions") return firstly { URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/active-subscriptions", parameters: [:])) }.map { data, response -> [Subscription] in try self.validateApiResponse(data: data, response: response) let decoder = JSONDecoder() let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" decoder.dateDecodingStrategy = .formatted(formatter) var subscriptions = try decoder.decode([Subscription].self, from: data) DDLogInfo("API RESULT: active-subscriptions: \(subscriptions)") // sort subscriptions with highest tier at the top subscriptions.sort(by: { (sub1: Subscription, sub2: Subscription) -> Bool in let p1 = Subscription.PlanType.precedence(p: sub1.planType) let p2 = Subscription.PlanType.precedence(p: sub2.planType) return p1 <= p2 }) DDLogInfo("API RESULT: sorted-active-subscriptions: \(subscriptions)") return subscriptions } } // For creating email account only - not signing up with IAP receipt static func signup(email: String, password: String) throws -> Promise { DDLogInfo("API CALL: signup") return firstly { () -> Promise<(data: Data, response: URLResponse)> in let parameters:[String : Any] = [ "email" : email, "password" : password, "lockdown": true ] return URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/signup", parameters: parameters)) } .map { data, response -> Signup in try self.validateApiResponse(data: data, response: response) let signup = try JSONDecoder().decode(Signup.self, from: data) DDLogInfo("API RESULT: signup: \(signup)") return signup } } static func forgotPassword(email: String) throws -> Promise { DDLogInfo("API CALL: forgot-password") return firstly { () -> Promise<(data: Data, response: URLResponse)> in let parameters:[String : Any] = [ "email" : email, "lockdown": true ] return URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/forgot-password", parameters: parameters)) } .map { data, response -> Bool in if let httpResponse = response as? HTTPURLResponse { DDLogInfo("API RESULT: forgot-password: \(httpResponse.statusCode)") if httpResponse.statusCode < 400 { return true } if let error = try? JSONDecoder().decode(ApiError.self, from: data) { throw error } throw ApiError( code: httpResponse.statusCode, message: "Unknown error" ) } DDLogInfo("API RESULT: error - forgot-password: not HTTPURLResponse") return false } } static func getKey() throws -> Promise { DDLogInfo("API CALL: getKey") return firstly { () -> Promise<(data: Data, response: URLResponse)> in let parameters:[String : Any] = [ "platform" : "ios", "lockdown": true ] return URLSession.shared.dataTask(.promise, with: try makePostRequest(urlString: mainURL + "/get-key", parameters: parameters)) } .map { data, response -> GetKey in try self.validateApiResponse(data: data, response: response) let getKey = try JSONDecoder().decode(GetKey.self, from: data) DDLogInfo("API RESULT: getKey: \(getKey)") return getKey } } static func getSpeedTestBucket() -> Promise { DDLogInfo("API CALL: download speed test") return firstly { URLSession.shared.dataTask(.promise, with: try makeGetRequest(urlString: "\(mainURL)/download-speed-test")) } .map { data, response -> SpeedTestBucket in try self.validateApiResponse(data: data, response: response) let speedTestBucket = try JSONDecoder().decode(SpeedTestBucket.self, from: data) DDLogInfo("API RESULT: speedTestBucket: \(speedTestBucket)") return speedTestBucket } } static func getIP() -> Promise { DDLogInfo("API CALL: ip") URLCache.shared.removeAllCachedResponses() return firstly { URLSession.shared.dataTask(.promise, with: try makeGetRequest(urlString: "https://ip.\(mainDomain)/ip")) } .map { data, response -> IP in try self.validateApiResponse(data: data, response: response) let ip = try JSONDecoder().decode(IP.self, from: data) DDLogInfo("API RESULT: ip: \(ip)") return ip } } static func getBlockedDomainTest() -> Promise { return firstly { URLSession.shared.dataTask(.promise, with: try Client.makeGetRequest(urlString: "https://\(testFirewallDomain)")) }.asVoid() } // MARK: - Request Makers static func makeGetRequest(urlString: String) throws -> URLRequest { DDLogInfo("makeGetRequest: \(urlString)") if let url = URL(string: urlString) { var rq = URLRequest(url: url) rq.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData rq.httpMethod = "GET" rq.addValue("application/json", forHTTPHeaderField: "Accept") return rq } else { throw "Invalid URL string: \(urlString)" } } static func makePostRequest(urlString: String, parameters: [String: Any]) throws -> URLRequest { DDLogInfo("makePostRequest: \(urlString)")//", parameters: \(parameters)") if let url = URL(string: urlString) { var rq = URLRequest(url: url) rq.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData rq.httpMethod = "POST" rq.addValue("application/json", forHTTPHeaderField: "Content-Type") rq.addValue("application/json", forHTTPHeaderField: "Accept") rq.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) return rq } else { throw "Invalid URL string: \(urlString)" } } // MARK: - Util static func getReceipt(forceRefresh: Bool) -> Promise { DDLogInfo("fetch and set latest receipt") return Promise { seal in SwiftyStoreKit.fetchReceipt(forceRefresh: forceRefresh) { result in switch result { case .success(let receiptData): let receipt = receiptData.base64EncodedString(options: []) DDLogInfo("fetch latest receipt success base64: \(receipt)") seal.fulfill(receipt); case .error(let error): DDLogError("fetch latest receipt failure: \(error)") do { switch error { case ReceiptError.noReceiptData: throw "Error refreshing purchases with App Store: No Receipt Data" case ReceiptError.networkError(let networkError): throw "Error refreshing purchases with App Store: Network Error - \(networkError.localizedDescription)" case ReceiptError.noRemoteData: throw "Error refreshing purchases with App Store: No Remote Data" case ReceiptError.receiptInvalid(_, let receiptStatus): throw "Error refreshing purchases with App Store: Invalid Receipt - \(receiptStatus)" case ReceiptError.requestBodyEncodeError(let error): throw "Error refreshing purchases with App Store: Encoding Error - \(error.localizedDescription)" case ReceiptError.jsonDecodeError(_): throw "Error refreshing purchases with App Store: JSON Decode Error" } } catch { seal.reject(error) } } } } } static func hasValidCookie() -> Bool { DDLogInfo("checking for valid cookie") var hasValidCookie = false if let cookies = HTTPCookieStorage.shared.cookies { DDLogInfo("found cookies") for cookie in cookies { DDLogInfo("cookie: \(cookie)") if let timeUntilExpire = cookie.expiresDate?.timeIntervalSinceNow { DDLogInfo("time until expire: \(timeUntilExpire)") if cookie.domain.contains(mainDomain) && timeUntilExpire > 120.0 { DDLogInfo("cookie contains mainDomain and timeuntilexpires > 120") hasValidCookie = true } } } } return hasValidCookie } static func clearCookies() { DDLogInfo("clearing cookies") var cookiesToDelete:[HTTPCookie] = [] if let cookies = HTTPCookieStorage.shared.cookies { DDLogInfo("found cookies") for cookie in cookies { DDLogInfo("cookie to delete: \(cookie)") cookiesToDelete.append(cookie) } } for cookie in cookiesToDelete { DDLogInfo("deleting cookie: \(cookie)") HTTPCookieStorage.shared.deleteCookie(cookie) } } private static func validateApiResponse(data: Data, response: URLResponse) throws { DDLogInfo("validating API response") let dataString = String(data: data, encoding: String.Encoding.utf8) DDLogInfo("RAW RESULT: \(String(describing: dataString))") if let resp = response as? HTTPURLResponse { DDLogInfo("response is HTTPURLResponse: \(resp)") // see if there's a non-zero code returned if let apiError = try? JSONDecoder().decode(ApiError.self, from: data) { if apiError.code == kApiCodeNoError { DDLogError("zero (non-error) API code received, validated OK: \(apiError)") return; } else { DDLogError("nonzero API code received, throwing: \(apiError)") throw apiError; } } // some 4xx/5xx error else if (resp.statusCode >= 400 || resp.statusCode <= 0) { DDLogError("response has bad status code \(resp.statusCode)") throw "response has bad status code \(resp.statusCode)" } else { DDLogInfo("response has good status code (2xx, 3xx) and no error code") } } else { throw "Invalid URL Response received" } } }