Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Network/HttpHeaders.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import UIKit

// This class can be used to build "outgoing" headers for requests
// or to parse "incoming" headers for responses.
//
// HTTP headers are case-insensitive.
// This class handles conflict resolution.
public struct HttpHeaders: Codable, CustomDebugStringConvertible, ExpressibleByDictionaryLiteral {
    public private(set) var headers = [String: String]()

    public init() {}

    public init(dictionaryLiteral headerElements: (String, String)...) {
        for (headerKey, headerValue) in headerElements {
            self[headerKey] = headerValue
        }
    }

    public init(httpHeaders: [String: String]?, overwriteOnConflict: Bool) {
        self.init()
        addHeaderMap(httpHeaders, overwriteOnConflict: overwriteOnConflict)
    }

    public init(response: HTTPURLResponse) {
        self.init()
        for (key, value) in response.allHeaderFields {
            guard let key = key as? String, let value = value as? String else {
                owsFailDebug("Invalid response header, key: \(key), value: \(value).")
                continue
            }
            addHeader(key, value: value, overwriteOnConflict: true)
        }
    }

    // MARK: -

    public func encode(to encoder: any Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.headers)
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.singleValueContainer()
        self.addHeaderMap(try container.decode([String: String].self), overwriteOnConflict: true)
    }

    // MARK: -

    public func hasValueForHeader(_ header: String) -> Bool {
        return headers[header.lowercased()] != nil
    }

    public func value(forHeader header: String) -> String? {
        return headers[header.lowercased()]
    }

    public mutating func removeValueForHeader(_ header: String) {
        headers.removeValue(forKey: header.lowercased())
    }

    public mutating func addHeader(_ header: String, value: String, overwriteOnConflict: Bool) {
        addHeaderMap([header: value], overwriteOnConflict: overwriteOnConflict)
    }

    public subscript(_ headerKey: String) -> String? {
        get {
            return self.value(forHeader: headerKey)
        }
        set {
            if let newValue {
                self.addHeader(headerKey, value: newValue, overwriteOnConflict: true)
            } else {
                self.removeValueForHeader(headerKey)
            }
        }
    }

    public mutating func merge(_ httpHeaders: HttpHeaders) {
        self.addHeaderMap(httpHeaders.headers, overwriteOnConflict: true)
    }

    public mutating func addHeaderMap(_ newHttpHeaders: [String: String]?, overwriteOnConflict: Bool) {
        guard let newHttpHeaders else {
            return
        }
        for (key, value) in newHttpHeaders {
            let key = key.lowercased()
            if let existingValue = self.value(forHeader: key) {
                if value == existingValue {
                    // Don't warn about redundant changes.
                } else if overwriteOnConflict {
                    // We expect to overwrite the User-Agent; don't log it.
                    if key != Self.userAgentHeaderKey.lowercased() {
                        Logger.verbose("Overwriting header: \(key), \(existingValue) -> \(value)")
                    }
                } else if key == Self.acceptLanguageHeaderKey.lowercased() {
                    // Don't warn about default headers.
                    continue
                } else if key == Self.userAgentHeaderKey.lowercased() {
                    // Don't warn about default headers.
                    continue
                } else {
                    owsFailDebug("Skipping redundant header: \(key)")
                    continue
                }
            }
            headers[key] = value
        }
    }

    public mutating func addHeaderList(_ newHttpHeaders: [String]?, overwriteOnConflict: Bool) {
        guard let newHttpHeaders else {
            return
        }
        for header in newHttpHeaders {
            guard let header = header.strippedOrNil else {
                owsFailDebug("Empty header.")
                continue
            }
            guard let index = header.firstIndex(of: ":") else {
                Logger.warn("Invalid header: \(header).")
                owsFailDebug("Invalid header.")
                continue
            }
            let beforeColonIndex = index
            let afterColonIndex = header.index(index, offsetBy: 1)
            guard let key = String(header.prefix(upTo: beforeColonIndex)).strippedOrNil else {
                Logger.warn("Invalid header key: \(header).")
                owsFailDebug("Invalid header key.")
                continue
            }
            guard let value = String(header.suffix(from: afterColonIndex)).strippedOrNil else {
                Logger.warn("Invalid header value: \(header), key: \(key).")
                owsFailDebug("Invalid header value.")
                continue
            }
            self.addHeader(key, value: value, overwriteOnConflict: overwriteOnConflict)
        }
    }

    // MARK: - Default Headers

    public static var userAgentHeaderKey: String { "User-Agent" }

    public static var userAgentHeaderValueSignalIos: String {
        "Signal-iOS/\(AppVersionImpl.shared.currentAppVersion) iOS/\(UIDevice.current.systemVersion)"
    }

    public static var acceptLanguageHeaderKey: String { "Accept-Language" }

    /// See [RFC4647](https://www.rfc-editor.org/rfc/rfc4647#section-2.2).
    private static let languageRangeRegex = try! NSRegularExpression(pattern: #"^(?:\*|[a-z]{1,8})(?:-(?:\*|(?:[a-z0-9]{1,8})))*$"#, options: .caseInsensitive)

    /// Returns the top 10 languages preferred by the user, so they can be sent to the server.
    ///
    /// Languages are assumed to be in order of preference.
    ///
    /// Languages that aren't valid per [RFC4647][] are omitted, because the server also does this validation.
    ///
    /// [RFC4647]: https://www.rfc-editor.org/rfc/rfc4647#section-2.2
    static func topPreferredLanguages(_ languages: [String] = Locale.preferredLanguages) -> some Sequence<String> {
        return languages
            .lazy
            .filter { languageRangeRegex.hasMatch(input: $0) }
            .prefix(10)
    }

    /// Format languages for the `Accept-Language` header per [RFC9110][0].
    ///
    /// Languages should be passed in order of preference.
    ///
    /// Languages that aren't valid per [RFC4647][1] are omitted, because the server also does this validation.
    ///
    /// Up to 10 valid languages are returned.
    /// This is for simplicity, and also to [avoid generating 'q' values that are too long][2].
    ///
    /// [0]: https://www.rfc-editor.org/rfc/rfc9110.html#name-accept-language
    /// [1]: https://www.rfc-editor.org/rfc/rfc4647#section-2.2
    /// [2]: https://www.rfc-editor.org/rfc/rfc9110.html#quality.values
    static func formatAcceptLanguageHeader(_ languages: [String]) -> String {
        let formattedLanguages = topPreferredLanguages(languages)
            .enumerated()
            .map { idx, language -> String in
                let q = 1.0 - (Float(idx) * 0.1)
                // ["If no 'q' parameter is present, the default weight is 1."][0]
                // [0]: https://www.rfc-editor.org/rfc/rfc9110.html#section-12.4.2
                if q == 1 { return language }
                return language + String(format: ";q=%0.1g", q)
            }
        return formattedLanguages.isEmpty ? "*" : formattedLanguages.joined(separator: ", ")
    }

    public static var acceptLanguageHeaderValue: String {
        formatAcceptLanguageHeader(Locale.preferredLanguages)
    }

    public mutating func addDefaultHeaders() {
        addHeader(Self.userAgentHeaderKey, value: Self.userAgentHeaderValueSignalIos, overwriteOnConflict: false)
        addHeader(Self.acceptLanguageHeaderKey, value: Self.acceptLanguageHeaderValue, overwriteOnConflict: false)
    }

    // MARK: - Auth Headers

    public static var authHeaderKey: String { "Authorization" }

    public static func authHeaderValue(username: String, password: String) -> String {
        let data = Data("\(username):\(password)".utf8)
        return "Basic " + data.base64EncodedString()
    }

    public mutating func addAuthHeader(username: String, password: String) {
        let value = Self.authHeaderValue(username: username, password: password)
        addHeader(Self.authHeaderKey, value: value, overwriteOnConflict: true)
    }

    public static func fillInMissingDefaultHeaders(request: URLRequest) -> URLRequest {
        var request = request
        var httpHeaders = HttpHeaders()
        httpHeaders.addHeaderMap(request.allHTTPHeaderFields, overwriteOnConflict: true)
        httpHeaders.addDefaultHeaders()
        request.set(httpHeaders: httpHeaders)
        return request
    }

    // MARK: - NSObject Overrides

    static let whitelistedLoggedHeaderKeys = Set([
        "retry-after",
        "x-signal-timestamp",
        // Have a header key who's value you'd like to see logged? Add it here (lowercased)!
    ])

    public var debugDescription: String {
        var headerValues = [String]()
        for (key, value) in headers.sorted(by: { $0.key < $1.key }) {
            if Self.whitelistedLoggedHeaderKeys.contains(key) {
                headerValues.append("\(key): \(value)")
            } else {
                headerValues.append(key)
            }
        }
        return "<\(type(of: self)): [\(headerValues.joined(separator: "; "))]>"
    }
}

// MARK: - HTTP Headers

public extension URLRequest {
    mutating func set(httpHeaders: HttpHeaders) {
        for (headerField, headerValue) in httpHeaders.headers {
            setValue(headerValue, forHTTPHeaderField: headerField)
        }
    }
}