Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FXIOS-731 ⁃ HTTP HEAD etags to cut down on data requests from Breach Alerts #7100

Merged
merged 14 commits into from
Aug 14, 2020
65 changes: 54 additions & 11 deletions Client/Frontend/Login Management/BreachAlertsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ struct BreachAlertsError: MaybeErrorType {
}
/// For mocking and testing BreachAlertsClient.
protocol BreachAlertsClientProtocol {
func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (_ result: Maybe<Data>) -> Void)
func fetchEtag(endpoint: BreachAlertsClient.Endpoint, profile: Profile, completion: @escaping (_ etag: String?) -> Void)
func fetchData(endpoint: BreachAlertsClient.Endpoint, profile: Profile, completion: @escaping (_ result: Maybe<Data>) -> Void)
}

/// Handles all network requests for BreachAlertsManager.
Expand All @@ -20,28 +21,70 @@ public class BreachAlertsClient: BreachAlertsClientProtocol {
public enum Endpoint: String {
case breachedAccounts = "https://monitor.firefox.com/hibp/breaches"
}
static let etagKey = "BreachAlertsDataEtag"
static let etagDateKey = "BreachAlertsDataDate"

/// Makes a network request to an endpoint and hands off the result to a completion handler.
public func fetchData(endpoint: Endpoint, completion: @escaping (_ result: Maybe<Data>) -> Void) {
guard let url = URL(string: endpoint.rawValue) else {
completion(Maybe(failure: BreachAlertsError(description: "bad endpoint URL")))
return
/// Makes a header-only request to an endpoint and hands off the endpoint's etag to a completion handler.
func fetchEtag(endpoint: Endpoint, profile: Profile, completion: @escaping (_ etag: String?) -> Void) {
guard let url = URL(string: endpoint.rawValue) else { return }
var request = URLRequest(url: url)
request.httpMethod = "HEAD"

dataTask?.cancel()
dataTask = URLSession.shared.dataTask(with: request) { _, response, _ in
guard let response = response as? HTTPURLResponse else { return }
guard response.statusCode < 400 else {
Sentry.shared.send(message: "BreachAlerts: fetchData: HTTP status code: \(response.statusCode)")
completion(nil)
return
}
guard let etag = response.allHeaderFields["Etag"] as Any as? String else {
completion(nil)
assert(false)
return
}
DispatchQueue.main.async {
completion(etag)
}
}
dataTask?.resume()
}

/// Makes a network request to an endpoint and hands off the result to a completion handler.
func fetchData(endpoint: Endpoint, profile: Profile, completion: @escaping (_ result: Maybe<Data>) -> Void) {
guard let url = URL(string: endpoint.rawValue) else { return }

dataTask?.cancel()
dataTask = URLSession.shared.dataTask(with: url) { data, response, error in
guard validatedHTTPResponse(response) != nil else {
completion(Maybe(failure: BreachAlertsError(description: "invalid HTTP response")))
guard let response = response as? HTTPURLResponse else { return }
guard response.statusCode < 400 else {
Sentry.shared.send(message: "BreachAlerts: fetchData: HTTP status code: \(response.statusCode)")
return
}
if let error = error {
completion(Maybe(failure: BreachAlertsError(description: error.localizedDescription)))
Sentry.shared.send(message: "BreachAlerts: fetchData: \(error)")
return
}
guard let data = data, !data.isEmpty else {
completion(Maybe(failure: BreachAlertsError(description: "empty data")))
guard let data = data else {
completion(Maybe(failure: BreachAlertsError(description: "invalid data")))
vphong marked this conversation as resolved.
Show resolved Hide resolved
Sentry.shared.send(message: "BreachAlerts: fetchData: invalid data")
assert(false)
return
}
completion(Maybe(success: data))

guard let etag = response.allHeaderFields["Etag"] as Any as? String else { return }
let date = Date.now()

if profile.prefs.stringForKey(BreachAlertsClient.etagKey) != etag {
profile.prefs.setString(etag, forKey: BreachAlertsClient.etagKey)
vphong marked this conversation as resolved.
Show resolved Hide resolved
}
if profile.prefs.timestampForKey(BreachAlertsClient.etagDateKey) != date {
profile.prefs.setTimestamp(date, forKey: BreachAlertsClient.etagDateKey)
}
DispatchQueue.main.async {
completion(Maybe(success: data))
}
}
dataTask?.resume()
}
Expand Down
142 changes: 101 additions & 41 deletions Client/Frontend/Login Management/BreachAlertsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,50 +30,66 @@ final public class BreachAlertsManager {
static let detailColor = UIColor(red: 0.59, green: 0.11, blue: 0.11, alpha: 1.00)
static let monitorAboutUrl = URL(string: "https://monitor.firefox.com/about")
var breaches = Set<BreachRecord>()
var breachAlertsClient: BreachAlertsClientProtocol

init(_ client: BreachAlertsClientProtocol = BreachAlertsClient()) {
self.breachAlertsClient = client
var client: BreachAlertsClientProtocol
var profile: Profile!
private lazy var cacheURL: URL = {
return URL(fileURLWithPath: (try? self.profile.files.getAndEnsureDirectory())!, isDirectory: true).appendingPathComponent("breaches.json")
vphong marked this conversation as resolved.
Show resolved Hide resolved
}()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Looks good!

private let dateFormatter = DateFormatter()
init(_ client: BreachAlertsClientProtocol = BreachAlertsClient(), profile: Profile) {
self.client = client
self.profile = profile
}

/// Loads breaches from Monitor endpoint using BreachAlertsClient.
/// - Parameters:
/// - completion: a completion handler for the processed breaches
func loadBreaches(completion: @escaping (Maybe<Set<BreachRecord>>) -> Void) {
self.breachAlertsClient.fetchData(endpoint: .breachedAccounts) { maybeData in
guard let data = maybeData.successValue else {
completion(Maybe(failure: BreachAlertsError(description: "failed to load breaches data")))
return
}
guard let decoded = try? JSONDecoder().decode(Set<BreachRecord>.self, from: data) else {
completion(Maybe(failure: BreachAlertsError(description: "JSON data decode failure")))

// 1. check for local breaches file
guard FileManager.default.fileExists(atPath: self.cacheURL.path) else {
// 1a. no local file, so fetch and save as normal and hand off
self.fetchAndSaveBreaches(completion)
return
}

// 1b. local file exists, so load from that
guard let fileData = FileManager.default.contents(atPath: self.cacheURL.path) else {
completion(Maybe(failure: BreachAlertsError(description: "failed to get data from breach.json")))
Sentry.shared.send(message: "BreachAlerts: failed to get data from breach.json")
try? FileManager.default.removeItem(at: self.cacheURL) // bad file, so delete it
self.fetchAndSaveBreaches(completion)
return
}

// 2. check the last time breach endpoint was accessed
guard let dateLastAccessed = profile.prefs.timestampForKey(BreachAlertsClient.etagDateKey) else {
profile.prefs.removeObjectForKey(BreachAlertsClient.etagDateKey) // bad key, so delete it
self.fetchAndSaveBreaches(completion)
return
}
let timeUntilNextUpdate = UInt64(60 * 60 * 24 * 3 * 1000) // 3 days in milliseconds
let shouldUpdateDate = dateLastAccessed + timeUntilNextUpdate

// 3. if 3 days have not passed since last update...
guard Date.now() >= shouldUpdateDate else {
// 3a. no need to refetch. decode local data and hand off
decodeData(data: fileData, completion)
return
}

// 3b. should update - check if the etag is different
client.fetchEtag(endpoint: .breachedAccounts, profile: self.profile) { etag in
guard let etag = etag else {
self.profile.prefs.removeObjectForKey(BreachAlertsClient.etagKey) // bad key, so delete it
return
}
let savedEtag = self.profile.prefs.stringForKey(BreachAlertsClient.etagKey)

self.breaches = decoded
// remove for release
self.breaches.insert(BreachRecord(
name: "MockBreach",
title: "A Mock Blockbuster Record",
domain: "blockbuster.com",
breachDate: "1970-01-02",
description: "A mock BreachRecord for testing purposes."
))
self.breaches.insert(BreachRecord(
name: "MockBreach",
title: "A Mock Lorem Ipsum Record",
domain: "lipsum.com",
breachDate: "1970-01-02",
description: "A mock BreachRecord for testing purposes."
))
self.breaches.insert(BreachRecord(
name: "MockBreach",
title: "A Mock Swift Breach Record",
domain: "swift.org",
breachDate: "1970-01-02",
description: "A mock BreachRecord for testing purposes."
))
completion(Maybe(success: self.breaches))
// 4. if it is, refetch the data and hand entire Set of BreachRecords off
if etag != savedEtag {
self.fetchAndSaveBreaches(completion)
}
vphong marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand All @@ -98,9 +114,8 @@ final public class BreachAlertsManager {
}
for item in potentialUserBreaches {
let pwLastChanged = TimeInterval(item.timePasswordChanged/1000)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
guard let breachDate = dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else {
self.dateFormatter.dateFormat = "yyyy-MM-dd"
guard let breachDate = self.dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else {
continue
}
result.insert(item)
Expand Down Expand Up @@ -136,18 +151,63 @@ final public class BreachAlertsManager {
let baseDomain = self.baseDomainForLogin(login)
for breach in self.breaches where breach.domain == baseDomain {
let pwLastChanged = TimeInterval(login.timePasswordChanged/1000)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
guard let breachDate = dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else {
self.dateFormatter.dateFormat = "yyyy-MM-dd"
guard let breachDate = self.dateFormatter.date(from: breach.breachDate)?.timeIntervalSince1970, pwLastChanged < breachDate else {
continue
}
return breach
}
return nil
}

// MARK: - Helper Functions
private func baseDomainForLogin(_ login: LoginRecord) -> String {
guard let result = login.hostname.asURL?.baseDomain else { return login.hostname }
return result
}

private func fetchAndSaveBreaches(_ completion: @escaping (Maybe<Set<BreachRecord>>) -> Void) {
self.client.fetchData(endpoint: .breachedAccounts, profile: self.profile) { maybeData in
guard let fetchedData = maybeData.successValue else { return }
try? FileManager.default.removeItem(atPath: self.cacheURL.path)
FileManager.default.createFile(atPath: self.cacheURL.path, contents: fetchedData, attributes: nil)

guard let data = FileManager.default.contents(atPath: self.cacheURL.path) else { return }
self.decodeData(data: data, completion)
}
}

private func decodeData(data: Data, _ completion: @escaping (Maybe<Set<BreachRecord>>) -> Void) {
guard let decoded = try? JSONDecoder().decode(Set<BreachRecord>.self, from: data) else {
print(BreachAlertsError(description: "JSON data decode failure"))
assert(false)
return
}

self.breaches = decoded
// remove for release
self.breaches.insert(BreachRecord(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the mocks pls

name: "MockBreach",
title: "A Mock Blockbuster Record",
domain: "blockbuster.com",
breachDate: "1970-01-02",
description: "A mock BreachRecord for testing purposes."
))
self.breaches.insert(BreachRecord(
name: "MockBreach",
title: "A Mock Lorem Ipsum Record",
domain: "lipsum.com",
breachDate: "1970-01-02",
description: "A mock BreachRecord for testing purposes."
))
self.breaches.insert(BreachRecord(
name: "MockBreach",
title: "A Mock Swift Breach Record",
domain: "swift.org",
breachDate: "1970-01-02",
description: "A mock BreachRecord for testing purposes."
))

completion(Maybe(success: self.breaches))
}
}
6 changes: 4 additions & 2 deletions Client/Frontend/Login Management/LoginListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ final class LoginListViewModel {
}
}
fileprivate let helper = LoginListDataSourceHelper()
private(set) var breachAlertsManager = BreachAlertsManager()
private(set) lazy var breachAlertsManager: BreachAlertsManager = {
return BreachAlertsManager(profile: self.profile)
}()
private(set) var userBreaches: Set<LoginRecord>?
private(set) var breachIndexPath = Set<IndexPath>() {
didSet {
Expand Down Expand Up @@ -137,7 +139,7 @@ final class LoginListViewModel {
}

func setBreachAlertsManager(_ client: BreachAlertsClientProtocol) {
self.breachAlertsManager = BreachAlertsManager(client)
self.breachAlertsManager = BreachAlertsManager(client, profile: profile)
}

// MARK: - UX Constants
Expand Down
5 changes: 3 additions & 2 deletions Client/Frontend/Settings/LoginDetailViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ extension LoginDetailViewController: UITableViewDataSource {
}
breachDetailView.setup(breach)

breachDetailView.learnMoreButton.addTarget(self, action: #selector(didTapBreachLearnMore), for: .touchUpInside)
let breachLinkGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBreachLink(_:)))
breachDetailView.learnMoreButton.addTarget(self, action: #selector(LoginDetailViewController.didTapBreachLearnMore), for: .touchUpInside)
let breachLinkGesture = UITapGestureRecognizer(target: self, action: #selector(LoginDetailViewController
.didTapBreachLink(_:)))
breachDetailView.goToButton.addGestureRecognizer(breachLinkGesture)
breachCell.isAccessibilityElement = false
breachCell.contentView.accessibilityElementsHidden = true
Expand Down
9 changes: 7 additions & 2 deletions ClientTests/BreachAlertsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,19 @@ let longBreach = BreachRecord(
)
let unbreachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://unbreached.com", "timePasswordChanged": 1594411049000])
let breachedLogin = LoginRecord(fromJSONDict: ["hostname" : "http://blockbuster.com", "timePasswordChanged": 46800000])

class MockBreachAlertsClient: BreachAlertsClientProtocol {
func fetchData(endpoint: BreachAlertsClient.Endpoint, completion: @escaping (Maybe<Data>) -> Void) {
func fetchEtag(endpoint: BreachAlertsClient.Endpoint, profile: Client.Profile, completion: @escaping (String?) -> Void) {
completion("33a64df551425fcc55e4d42a148795d9f25f89d4")
}
func fetchData(endpoint: BreachAlertsClient.Endpoint, profile: Client.Profile, completion: @escaping (Maybe<Data>) -> Void) {
guard let mockData = try? JSONEncoder().encode([blockbusterBreach].self) else {
completion(Maybe(failure: BreachAlertsError(description: "failed to encode mockRecord")))
return
}
completion(Maybe(success: mockData))
}
var etag: String? = nil
}

class BreachAlertsTests: XCTestCase {
Expand All @@ -47,7 +52,7 @@ class BreachAlertsTests: XCTestCase {
let breachedLoginSet = Set<LoginRecord>(arrayLiteral: breachedLogin)

override func setUp() {
self.breachAlertsManager = BreachAlertsManager(MockBreachAlertsClient())
self.breachAlertsManager = BreachAlertsManager(MockBreachAlertsClient(), profile: MockProfile())
}

func testDataRequest() {
Expand Down