From d3d737da861fbdd6b5038c523c9b0e917ed3a5f7 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sun, 6 Sep 2020 21:56:18 -0700 Subject: [PATCH] Instance filter --- .../HTTP/{Client.swift => HTTPClient.swift} | 14 ++-- .../Stubbing/StubbingURLProtocol.swift | 16 +++-- .../MastodonAPI/MastodonAPIClient.swift | 2 +- ServiceLayer/Package.swift | 3 +- .../Services/AllIdentitiesService.swift | 2 + .../Services/AuthenticationService.swift | 2 +- .../Services/InstanceFilterService.swift | 71 +++++++++++++++++++ .../Utilities/UserDefaultsClient.swift | 45 ++++++++++++ .../InstanceFilterTests.swift | 53 ++++++++++++++ .../ViewModels/AddIdentityViewModel.swift | 36 +++++++++- .../Extensions/String+Extensions.swift | 21 ------ Views/AddIdentityView.swift | 1 + 12 files changed, 232 insertions(+), 34 deletions(-) rename HTTP/Sources/HTTP/{Client.swift => HTTPClient.swift} (84%) create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/InstanceFilterService.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift create mode 100644 ServiceLayer/Tests/ServiceLayerTests/InstanceFilterTests.swift delete mode 100644 ViewModels/Sources/ViewModels/Extensions/String+Extensions.swift diff --git a/HTTP/Sources/HTTP/Client.swift b/HTTP/Sources/HTTP/HTTPClient.swift similarity index 84% rename from HTTP/Sources/HTTP/Client.swift rename to HTTP/Sources/HTTP/HTTPClient.swift index 2ee741c..6f3fec9 100644 --- a/HTTP/Sources/HTTP/Client.swift +++ b/HTTP/Sources/HTTP/HTTPClient.swift @@ -6,7 +6,7 @@ import Foundation public typealias Session = Alamofire.Session -open class Client { +open class HTTPClient { private let session: Session private let decoder: DataDecoder @@ -16,7 +16,7 @@ open class Client { } open func request(_ target: T) -> AnyPublisher { - requestPublisher(target).value().mapError { $0 as Error }.eraseToAnyPublisher() + requestPublisher(target).value().mapError { $0.underlyingOrTypeErased }.eraseToAnyPublisher() } public func request( @@ -35,14 +35,14 @@ open class Client { throw decodedError } - throw error + throw error.underlyingOrTypeErased } } .eraseToAnyPublisher() } } -private extension Client { +private extension HTTPClient { func requestPublisher(_ target: T) -> DataResponsePublisher { if let protocolClasses = session.sessionConfiguration.protocolClasses { for protocolClass in protocolClasses { @@ -55,3 +55,9 @@ private extension Client { .publishDecodable(type: T.ResultType.self, queue: session.rootQueue, decoder: decoder) } } + +private extension AFError { + var underlyingOrTypeErased: Error { + underlyingError ?? self + } +} diff --git a/HTTP/Sources/Stubbing/StubbingURLProtocol.swift b/HTTP/Sources/Stubbing/StubbingURLProtocol.swift index fa977f5..1488933 100644 --- a/HTTP/Sources/Stubbing/StubbingURLProtocol.swift +++ b/HTTP/Sources/Stubbing/StubbingURLProtocol.swift @@ -5,6 +5,7 @@ import HTTP public class StubbingURLProtocol: URLProtocol { private static var targetsForURLs = [URL: Target]() + private static var stubsForURLs = [URL: HTTPStub]() override public class func canInit(with task: URLSessionTask) -> Bool { true @@ -21,24 +22,31 @@ public class StubbingURLProtocol: URLProtocol { override public func startLoading() { guard let url = request.url, - let stub = Self.stub(request: request, target: Self.targetsForURLs[url]) else { -// preconditionFailure("Stub for request not found") + let stub = Self.stubsForURLs[url] + ?? Self.stub(request: request, target: Self.targetsForURLs[url]) else { return } switch stub { case let .success((response, data)): - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) case let .failure(error): client?.urlProtocol(self, didFailWithError: error) } + + client?.urlProtocolDidFinishLoading(self) } override public func stopLoading() {} } +public extension StubbingURLProtocol { + static func setStub(_ stub: HTTPStub, forURL url: URL) { + stubsForURLs[url] = stub + } +} + private extension StubbingURLProtocol { class func stub( request: URLRequest, diff --git a/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift b/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift index c330612..4ea62df 100644 --- a/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift +++ b/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift @@ -5,7 +5,7 @@ import Foundation import HTTP import Mastodon -public final class MastodonAPIClient: Client { +public final class MastodonAPIClient: HTTPClient { public var instanceURL: URL? public var accessToken: String? diff --git a/ServiceLayer/Package.swift b/ServiceLayer/Package.swift index 810ccce..fedc164 100644 --- a/ServiceLayer/Package.swift +++ b/ServiceLayer/Package.swift @@ -18,6 +18,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")), + .package(path: "CodableBloomFilter"), .package(path: "DB"), .package(path: "Keychain"), .package(path: "MastodonAPI"), @@ -26,7 +27,7 @@ let package = Package( targets: [ .target( name: "ServiceLayer", - dependencies: ["DB", "MastodonAPI", "Secrets"]), + dependencies: ["CodableBloomFilter", "DB", "MastodonAPI", "Secrets"]), .target( name: "ServiceLayerMocks", dependencies: [ diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift index d8bb59d..c6c3eb2 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift @@ -9,6 +9,7 @@ import Secrets public struct AllIdentitiesService { public let mostRecentlyUsedIdentityID: AnyPublisher + public let instanceFilterService: InstanceFilterService private let identityDatabase: IdentityDatabase private let environment: AppEnvironment @@ -22,6 +23,7 @@ public struct AllIdentitiesService { mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation() .replaceError(with: nil) .eraseToAnyPublisher() + instanceFilterService = InstanceFilterService(environment: environment) } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift index a87a7c3..a350524 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AuthenticationService.swift @@ -87,7 +87,7 @@ private extension AuthenticationService { case codeNotFound } - private func authorizationURL(instanceURL: URL, clientID: String) -> URL? { + func authorizationURL(instanceURL: URL, clientID: String) -> URL? { guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else { return nil } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/InstanceFilterService.swift b/ServiceLayer/Sources/ServiceLayer/Services/InstanceFilterService.swift new file mode 100644 index 0000000..c35fcfc --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/InstanceFilterService.swift @@ -0,0 +1,71 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import CodableBloomFilter +import Combine +import Foundation +import HTTP + +public struct InstanceFilterService { + private let httpClient: HTTPClient + private var userDefaultsClient: UserDefaultsClient + + init(environment: AppEnvironment) { + httpClient = HTTPClient(session: environment.session, decoder: JSONDecoder()) + userDefaultsClient = UserDefaultsClient(userDefaults: environment.userDefaults) + } +} + +public extension InstanceFilterService { + func isFiltered(url: URL) -> Bool { + guard let host = url.host else { return true } + + let allHostComponents = host.components(separatedBy: ".") + var hostComponents = [String]() + + for component in allHostComponents.reversed() { + hostComponents.insert(component, at: 0) + + if filter.contains(hostComponents.joined(separator: ".")) { + return true + } + } + + return false + } + + func updateFilter() -> AnyPublisher { + httpClient.request(UpdatedFilterTarget()) + .handleEvents(receiveOutput: { userDefaultsClient.updatedInstanceFilter = $0 }) + .map { _ in () } + .replaceError(with: ()) + .ignoreOutput() + .eraseToAnyPublisher() + } +} + +private struct UpdatedFilterTarget: DecodableTarget { + typealias ResultType = BloomFilter + + let baseURL = URL(string: "https://filter.metabolist.com")! + let pathComponents = ["filter.json"] + let method = HTTPMethod.get + let encoding: ParameterEncoding = JSONEncoding.default + let parameters: [String: Any]? = nil + let headers: HTTPHeaders? = nil +} + +private extension InstanceFilterService { + var filter: BloomFilter { + userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter + } + + static let updatedFilterUserDefaultsKey = "updatedFilter" + // Ugly, but baking this into the compiled app instead of loading the data from the bundle is more secure + // swiftlint:disable line_length + static let defaultFilterData = #"{"hashers":["djb2","djb2a","fnv1","fnv1a","sdbm"],"data":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAgAAAAAQAAAAAABAAACAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAABAAAEAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAIAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAIAAAQAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADAAAAAAAAAAAAA=="}"# + .data(using: .utf8)! + // swiftlint:enable line_length + // swiftlint:disable force_try + static let defaultFilter = try! JSONDecoder().decode(BloomFilter.self, from: defaultFilterData) + // swiftlint:enable force_try +} diff --git a/ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift b/ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift new file mode 100644 index 0000000..dbfb977 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift @@ -0,0 +1,45 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import CodableBloomFilter +import Foundation + +class UserDefaultsClient { + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + } +} + +extension UserDefaultsClient { + var updatedInstanceFilter: BloomFilter? { + get { + guard let data = self[.updatedFilter] as Data? else { + return nil + } + + return try? JSONDecoder().decode(BloomFilter.self, from: data) + } + + set { + var data: Data? + + if let newValue = newValue { + data = try? JSONEncoder().encode(newValue) + } + + self[.updatedFilter] = data + } + } +} + +private extension UserDefaultsClient { + enum Item: String { + case updatedFilter + } + + subscript(index: Item) -> T? { + get { userDefaults.value(forKey: index.rawValue) as? T } + set { userDefaults.set(newValue, forKey: index.rawValue) } + } +} diff --git a/ServiceLayer/Tests/ServiceLayerTests/InstanceFilterTests.swift b/ServiceLayer/Tests/ServiceLayerTests/InstanceFilterTests.swift new file mode 100644 index 0000000..dc26715 --- /dev/null +++ b/ServiceLayer/Tests/ServiceLayerTests/InstanceFilterTests.swift @@ -0,0 +1,53 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import CodableBloomFilter +import Combine +import CombineExpectations +@testable import ServiceLayer +@testable import ServiceLayerMocks +import Stubbing +import XCTest + +class InstanceFilterServiceTests: XCTestCase { + func testFiltering() throws { + let sut = InstanceFilterService(environment: .mock()) + let unfilteredInstanceURL = URL(string: "https://unfiltered.instance")! + let filteredInstanceURL = URL(string: "https://filtered.instance")! + let subdomainFilteredInstanceURL = URL(string: "https://subdomain.filtered.instance")! + + XCTAssertFalse(sut.isFiltered(url: unfilteredInstanceURL)) + XCTAssertTrue(sut.isFiltered(url: filteredInstanceURL)) + XCTAssertTrue(sut.isFiltered(url: subdomainFilteredInstanceURL)) + } + + func testUpdating() throws { + let environment = AppEnvironment.mock() + var sut = InstanceFilterService(environment: environment) + let previouslyFilteredInstanceURL = URL(string: "https://filtered.instance")! + let newlyFilteredInstanceURL = URL(string: "https://instance.filtered")! + + XCTAssertTrue(sut.isFiltered(url: previouslyFilteredInstanceURL)) + XCTAssertFalse(sut.isFiltered(url: newlyFilteredInstanceURL)) + + var updatedFilter = BloomFilter(hashers: [.djb2, .sdbm], byteCount: 16) + + updatedFilter.insert("instance.filtered") + + let updatedFilterData = try JSONEncoder().encode(updatedFilter) + let stub: HTTPStub = .success((URLResponse(), updatedFilterData)) + + StubbingURLProtocol.setStub(stub, forURL: URL(string: "https://filter.metabolist.com/filter.json")!) + + let updateRecorder = sut.updateFilter().collect().record() + + _ = try wait(for: updateRecorder.next(), timeout: 1) + + XCTAssertFalse(sut.isFiltered(url: previouslyFilteredInstanceURL)) + XCTAssertTrue(sut.isFiltered(url: newlyFilteredInstanceURL)) + + sut = InstanceFilterService(environment: environment) + + XCTAssertFalse(sut.isFiltered(url: previouslyFilteredInstanceURL)) + XCTAssertTrue(sut.isFiltered(url: newlyFilteredInstanceURL)) + } +} diff --git a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift index 217a8df..38a2c82 100644 --- a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift +++ b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift @@ -26,7 +26,7 @@ public extension AddIdentityViewModel { let instanceURL: URL do { - try instanceURL = urlFieldText.url() + instanceURL = try checkedURL() } catch { alertItem = AlertItem(error: error) @@ -37,6 +37,9 @@ public extension AddIdentityViewModel { .collect() .map { _ in (identityID, instanceURL) } .flatMap(allIdentitiesService.createIdentity(id:instanceURL:)) + .mapError { + return $0 + } .receive(on: DispatchQueue.main) .assignErrorsToAlertItem(to: \.alertItem, on: self) .handleEvents( @@ -55,7 +58,7 @@ public extension AddIdentityViewModel { let instanceURL: URL do { - try instanceURL = urlFieldText.url() + instanceURL = try checkedURL() } catch { alertItem = AlertItem(error: error) @@ -72,4 +75,33 @@ public extension AddIdentityViewModel { } receiveValue: { _ in } .store(in: &cancellables) } + + func refreshFilter() { + allIdentitiesService.instanceFilterService.updateFilter() + .sink { _ in } + .store(in: &cancellables) + } +} + +private extension AddIdentityViewModel { + private static let filteredURL = URL(string: "https://filtered")! + private static let HTTPSPrefix = "https://" + + func checkedURL() throws -> URL { + let url: URL + + if urlFieldText.hasPrefix(Self.HTTPSPrefix), let prefixedURL = URL(string: urlFieldText) { + url = prefixedURL + } else if let unprefixedURL = URL(string: Self.HTTPSPrefix + urlFieldText) { + url = unprefixedURL + } else { + throw URLError(.badURL) + } + + if allIdentitiesService.instanceFilterService.isFiltered(url: url) { + return Self.filteredURL + } + + return url + } } diff --git a/ViewModels/Sources/ViewModels/Extensions/String+Extensions.swift b/ViewModels/Sources/ViewModels/Extensions/String+Extensions.swift deleted file mode 100644 index 116bb48..0000000 --- a/ViewModels/Sources/ViewModels/Extensions/String+Extensions.swift +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Foundation - -extension String { - private static let HTTPSPrefix = "https://" - - func url() throws -> URL { - let url: URL? - - if hasPrefix(Self.HTTPSPrefix) { - url = URL(string: self) - } else { - url = URL(string: Self.HTTPSPrefix + self) - } - - guard let validURL = url else { throw URLError(.badURL) } - - return validURL - } -} diff --git a/Views/AddIdentityView.swift b/Views/AddIdentityView.swift index e6e149c..34080f1 100644 --- a/Views/AddIdentityView.swift +++ b/Views/AddIdentityView.swift @@ -31,6 +31,7 @@ struct AddIdentityView: View { rootViewModel.newIdentitySelected(id: id) } } + .onAppear(perform: viewModel.refreshFilter) } }