// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalCoreKit import CFNetwork /// This extension sacrifices Dictionary performance in order to ignore http /// header case and should not be generally used. Since the number of http /// headers is generally small, this is an acceptable tradeoff for this use case /// but may not be for other use cases. private extension Dictionary where Key == String { subscript(header header: String) -> Value? { get { if let key = keys.first(where: { $0.caseInsensitiveCompare(header) == .orderedSame }) { return self[key] } return nil } set { if let key = keys.first(where: { $0.caseInsensitiveCompare(header) == .orderedSame }) { self[key] = newValue } else { self[header] = newValue } } } } class HTTPUtils: Dependencies { #if TESTABLE_BUILD public static func logCurl(for task: URLSessionTask) { guard let request = task.originalRequest else { Logger.debug("attempted to log curl on a task with no original request") return } logCurl(for: request) } public static func logCurl(for request: URLRequest) { guard let httpMethod = request.httpMethod else { Logger.debug("attempted to log curl on a request with no http method") return } var curlComponents = ["curl", "-v", "-k", "-X", httpMethod] for (header, headerValue) in request.allHTTPHeaderFields ?? [:] { // We don't yet support escaping header values. // If these asserts trip, we'll need to add that. owsAssertDebug(!header.contains("'")) owsAssertDebug(!headerValue.contains("'")) curlComponents.append("-H") curlComponents.append("'\(header): \(headerValue)'") } if let httpBody = request.httpBody, !httpBody.isEmpty { let contentType = request.allHTTPHeaderFields?[header: "Content-Type"] switch contentType { case MimeType.applicationJson.rawValue: guard let jsonBody = String(data: httpBody, encoding: .utf8) else { Logger.debug("data attached to request as json was not utf8 encoded") return } // We don't yet support escaping JSON. // If these asserts trip, we'll need to add that. owsAssertDebug(!jsonBody.contains("'")) curlComponents.append("--data-ascii") curlComponents.append("'\(jsonBody)'") case MimeType.applicationXProtobuf.rawValue, "application/x-www-form-urlencoded", "application/vnd.signal-messenger.mrm": let filename = "\(UUID().uuidString).tmp" var echoBytes = "" for byte in httpBody { echoBytes.append(String(format: "\\\\x%02X", byte)) } let echoCommand = "echo -n -e \(echoBytes) > \(filename)" Logger.verbose("curl for request: \(echoCommand)") curlComponents.append("--data-binary") curlComponents.append("@\(filename)") default: owsFailDebug("Unknown content type: \(contentType ?? "")") } } // TODO: Add support for cookies. guard let url = request.url else { Logger.debug("attempted to log curl on a request with no url") return } curlComponents.append("\"\(url.absoluteString)\"") let curlCommand = curlComponents.joined(separator: " ") Logger.verbose("curl for request: \(curlCommand)") } #endif // This DRYs up handling of main service errors // so that REST and websocket errors are handled // in the same way. public static func preprocessMainServiceHTTPError( request: TSRequest, requestUrl: URL, responseStatus: Int, responseHeaders: OWSHttpHeaders, responseData: Data? ) -> OWSHTTPError { let httpError = HTTPUtils.buildServiceError( request: request, requestUrl: requestUrl, responseStatus: responseStatus, responseHeaders: responseHeaders, responseData: responseData ) applyHTTPError(httpError) #if TESTABLE_BUILD HTTPUtils.logCurl(for: request as URLRequest) #endif return httpError } // This DRYs up handling of main service errors so that // REST and websocket errors are handled in the same way. public static func applyHTTPError(_ httpError: OWSHTTPError) { if httpError.isNetworkConnectivityError { Self.outageDetection.reportConnectionFailure() } if httpError.responseStatusCode == AppExpiryImpl.appExpiredStatusCode { let appExpiry = DependenciesBridge.shared.appExpiry let db = DependenciesBridge.shared.db appExpiry.setHasAppExpiredAtCurrentVersion(db: db) } } private static func buildServiceError( request: TSRequest, requestUrl: URL, responseStatus: Int, responseHeaders: OWSHttpHeaders, responseData: Data? ) -> OWSHTTPError { let retryAfterDate: Date? = responseHeaders.retryAfterDate func buildServiceResponseError( localizedDescription: String? = nil, localizedRecoverySuggestion: String? = nil ) -> OWSHTTPError { .forServiceResponse( requestUrl: requestUrl, responseStatus: responseStatus, responseHeaders: responseHeaders, responseError: nil, responseData: responseData, customRetryAfterDate: retryAfterDate, customLocalizedDescription: localizedDescription, customLocalizedRecoverySuggestion: localizedRecoverySuggestion ) } switch responseStatus { case 0: return .networkFailure(requestUrl: requestUrl) case 413, 429: let description = OWSLocalizedString("REGISTER_RATE_LIMITING_ERROR", comment: "") let recoverySuggestion = OWSLocalizedString("REGISTER_RATE_LIMITING_BODY", comment: "") return buildServiceResponseError( localizedDescription: description, localizedRecoverySuggestion: recoverySuggestion ) case 417: // TODO: Is this response code obsolete? let description = OWSLocalizedString("REGISTRATION_ERROR", comment: "") let recoverySuggestion = OWSLocalizedString("RELAY_REGISTERED_ERROR_RECOVERY", comment: "") return buildServiceResponseError( localizedDescription: description, localizedRecoverySuggestion: recoverySuggestion ) default: return buildServiceResponseError() } } } // MARK: - public extension Error { var httpRetryAfterDate: Date? { HTTPUtils.httpRetryAfterDate(forError: self) } var httpResponseData: Data? { HTTPUtils.httpResponseData(forError: self) } var httpStatusCode: Int? { HTTPUtils.httpStatusCode(forError: self) } var httpRequestUrl: URL? { guard let error = self as? HTTPError else { return nil } return error.requestUrl } var httpResponseHeaders: OWSHttpHeaders? { guard let error = self as? HTTPError else { return nil } return error.responseHeaders } var httpResponseJson: Any? { guard let data = httpResponseData else { return nil } do { let json = try JSONSerialization.jsonObject(with: data, options: []) return json } catch { owsFailDebug("Could not parse JSON: \(error).") return nil } } var isNetworkFailureOrTimeout: Bool { HTTPUtils.isNetworkFailureOrTimeout(forError: self) } func hasFatalHttpStatusCode() -> Bool { guard let statusCode = self.httpStatusCode else { return false } if statusCode == 429 { // "Too Many Requests", retry with backoff. return false } return 400 <= statusCode && statusCode <= 499 } } // MARK: - public extension NSError { @objc @available(swift, obsoleted: 1.0) var httpStatusCode: NSNumber? { guard let statusCode = HTTPUtils.httpStatusCode(forError: self) else { return nil } owsAssertDebug(statusCode > 0) return NSNumber(value: statusCode) } @objc @available(swift, obsoleted: 1.0) var isNetworkFailureOrTimeout: Bool { HTTPUtils.isNetworkFailureOrTimeout(forError: self) } } // MARK: - // This extension contains the canonical implementations for // extracting various HTTP metadata from errors. They should // only be called from the convenience accessors on Error and // NSError above. fileprivate extension HTTPUtils { static func httpRetryAfterDate(forError error: Error) -> Date? { guard let httpError = error as? OWSHTTPError else { return nil } if let retryAfterDate = httpError.customRetryAfterDate { return retryAfterDate } if let retryAfterDate = httpError.responseHeaders?.retryAfterDate { return retryAfterDate } if let responseError = httpError.responseError { return httpRetryAfterDate(forError: responseError) } return nil } static func httpResponseData(forError error: Error) -> Data? { guard let httpError = error as? OWSHTTPError else { return nil } if let responseData = httpError.responseBodyData { return responseData } if let responseError = httpError.responseError { return httpResponseData(forError: responseError) } return nil } static func httpStatusCode(forError error: Error) -> Int? { guard let httpError = error as? OWSHTTPError else { return nil } let statusCode = httpError.responseStatusCode guard statusCode > 0 else { return nil } return statusCode } static func isNetworkFailureOrTimeout(forError error: Error?) -> Bool { guard let error else { return false } switch error { case URLError.timedOut: return true case URLError.cannotConnectToHost: return true case URLError.networkConnectionLost: return true case URLError.dnsLookupFailed: return true case URLError.notConnectedToInternet: return true case URLError.secureConnectionFailed: return true case URLError.cannotLoadFromNetwork: return true case URLError.cannotFindHost: return true case URLError.badURL: return true case POSIXError.EPROTO: return true case let httpError as OWSHTTPError: return httpError.isNetworkConnectivityError case GroupsV2Error.timeout: return true case PaymentsError.timeout: return true default: return false } } } // MARK: - @inlinable public func owsFailDebugUnlessNetworkFailure(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) { if error.isNetworkFailureOrTimeout { // Log but otherwise ignore network failures. Logger.warn("Error: \(error)", file: file, function: function, line: line) } else { owsFailDebug("Error: \(error)", file: file, function: function, line: line) } } @inlinable public func owsFailBetaUnlessNetworkFailure( _ error: Error, file: String = #file, function: String = #function, line: Int = #line ) { if error.isNetworkFailureOrTimeout { // Log but otherwise ignore network failures. Logger.warn("Error: \(error)", file: file, function: function, line: line) } else { owsFailBeta("Error: \(error)", file: file, function: function, line: line) } } // MARK: - extension NSError { @objc public func matchesDomainAndCode(of other: NSError) -> Bool { other.hasDomain(domain, code: code) } @objc public func hasDomain(_ domain: String, code: Int) -> Bool { self.domain == domain && self.code == code } } // MARK: - extension OWSHttpHeaders { // fallback retry-after delay if we fail to parse a non-empty retry-after string private static var kOWSFallbackRetryAfter: TimeInterval { 60 } private static var kOWSRetryAfterHeaderKey: String { "Retry-After" } public var retryAfterDate: Date? { if let retryAfterValue = value(forHeader: Self.kOWSRetryAfterHeaderKey) { return Self.parseRetryAfterHeaderValue(retryAfterValue) } else { return nil } } static func parseRetryAfterHeaderValue(_ rawValue: String?) -> Date? { guard let value = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { return nil } if let result = Date.ows_parseFromHTTPDateString(value) { return result } if let result = Date.ows_parseFromISO8601String(value) { return result } func parseWithScanner() -> Date? { // We need to use NSScanner instead of -[NSNumber doubleValue] so we can differentiate // because the NSNumber method returns 0.0 on a parse failure. NSScanner lets us detect // a parse failure. let scanner = Scanner(string: value) guard let delay = scanner.scanDouble(), scanner.isAtEnd else { // Only return the delay if we've made it to the end. // Helps to prevent things like: 8/11/1994 being interpreted as delay: 8. return nil } return Date(timeIntervalSinceNow: max(0, delay)) } if let result = parseWithScanner() { return result } if !CurrentAppContext().isRunningTests { owsFailDebug("Failed to parse retry-after string: \(String(describing: rawValue))") } return Date(timeIntervalSinceNow: Self.kOWSFallbackRetryAfter) } }