Instance filter

This commit is contained in:
Justin Mazzocchi 2020-09-06 21:56:18 -07:00
parent bb44676a73
commit d3d737da86
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
12 changed files with 232 additions and 34 deletions

View file

@ -6,7 +6,7 @@ import Foundation
public typealias Session = Alamofire.Session public typealias Session = Alamofire.Session
open class Client { open class HTTPClient {
private let session: Session private let session: Session
private let decoder: DataDecoder private let decoder: DataDecoder
@ -16,7 +16,7 @@ open class Client {
} }
open func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> { open func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
requestPublisher(target).value().mapError { $0 as Error }.eraseToAnyPublisher() requestPublisher(target).value().mapError { $0.underlyingOrTypeErased }.eraseToAnyPublisher()
} }
public func request<T: DecodableTarget, E: Error & Decodable>( public func request<T: DecodableTarget, E: Error & Decodable>(
@ -35,14 +35,14 @@ open class Client {
throw decodedError throw decodedError
} }
throw error throw error.underlyingOrTypeErased
} }
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
private extension Client { private extension HTTPClient {
func requestPublisher<T: DecodableTarget>(_ target: T) -> DataResponsePublisher<T.ResultType> { func requestPublisher<T: DecodableTarget>(_ target: T) -> DataResponsePublisher<T.ResultType> {
if let protocolClasses = session.sessionConfiguration.protocolClasses { if let protocolClasses = session.sessionConfiguration.protocolClasses {
for protocolClass in protocolClasses { for protocolClass in protocolClasses {
@ -55,3 +55,9 @@ private extension Client {
.publishDecodable(type: T.ResultType.self, queue: session.rootQueue, decoder: decoder) .publishDecodable(type: T.ResultType.self, queue: session.rootQueue, decoder: decoder)
} }
} }
private extension AFError {
var underlyingOrTypeErased: Error {
underlyingError ?? self
}
}

View file

@ -5,6 +5,7 @@ import HTTP
public class StubbingURLProtocol: URLProtocol { public class StubbingURLProtocol: URLProtocol {
private static var targetsForURLs = [URL: Target]() private static var targetsForURLs = [URL: Target]()
private static var stubsForURLs = [URL: HTTPStub]()
override public class func canInit(with task: URLSessionTask) -> Bool { override public class func canInit(with task: URLSessionTask) -> Bool {
true true
@ -21,24 +22,31 @@ public class StubbingURLProtocol: URLProtocol {
override public func startLoading() { override public func startLoading() {
guard guard
let url = request.url, let url = request.url,
let stub = Self.stub(request: request, target: Self.targetsForURLs[url]) else { let stub = Self.stubsForURLs[url]
// preconditionFailure("Stub for request not found") ?? Self.stub(request: request, target: Self.targetsForURLs[url]) else {
return return
} }
switch stub { switch stub {
case let .success((response, data)): 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?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
case let .failure(error): case let .failure(error):
client?.urlProtocol(self, didFailWithError: error) client?.urlProtocol(self, didFailWithError: error)
} }
client?.urlProtocolDidFinishLoading(self)
} }
override public func stopLoading() {} override public func stopLoading() {}
} }
public extension StubbingURLProtocol {
static func setStub(_ stub: HTTPStub, forURL url: URL) {
stubsForURLs[url] = stub
}
}
private extension StubbingURLProtocol { private extension StubbingURLProtocol {
class func stub( class func stub(
request: URLRequest, request: URLRequest,

View file

@ -5,7 +5,7 @@ import Foundation
import HTTP import HTTP
import Mastodon import Mastodon
public final class MastodonAPIClient: Client { public final class MastodonAPIClient: HTTPClient {
public var instanceURL: URL? public var instanceURL: URL?
public var accessToken: String? public var accessToken: String?

View file

@ -18,6 +18,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")), .package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")),
.package(path: "CodableBloomFilter"),
.package(path: "DB"), .package(path: "DB"),
.package(path: "Keychain"), .package(path: "Keychain"),
.package(path: "MastodonAPI"), .package(path: "MastodonAPI"),
@ -26,7 +27,7 @@ let package = Package(
targets: [ targets: [
.target( .target(
name: "ServiceLayer", name: "ServiceLayer",
dependencies: ["DB", "MastodonAPI", "Secrets"]), dependencies: ["CodableBloomFilter", "DB", "MastodonAPI", "Secrets"]),
.target( .target(
name: "ServiceLayerMocks", name: "ServiceLayerMocks",
dependencies: [ dependencies: [

View file

@ -9,6 +9,7 @@ import Secrets
public struct AllIdentitiesService { public struct AllIdentitiesService {
public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never> public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never>
public let instanceFilterService: InstanceFilterService
private let identityDatabase: IdentityDatabase private let identityDatabase: IdentityDatabase
private let environment: AppEnvironment private let environment: AppEnvironment
@ -22,6 +23,7 @@ public struct AllIdentitiesService {
mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation() mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation()
.replaceError(with: nil) .replaceError(with: nil)
.eraseToAnyPublisher() .eraseToAnyPublisher()
instanceFilterService = InstanceFilterService(environment: environment)
} }
} }

View file

@ -87,7 +87,7 @@ private extension AuthenticationService {
case codeNotFound 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 { guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
return nil return nil
} }

View file

@ -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<Never, Never> {
httpClient.request(UpdatedFilterTarget())
.handleEvents(receiveOutput: { userDefaultsClient.updatedInstanceFilter = $0 })
.map { _ in () }
.replaceError(with: ())
.ignoreOutput()
.eraseToAnyPublisher()
}
}
private struct UpdatedFilterTarget: DecodableTarget {
typealias ResultType = BloomFilter<String>
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<String> {
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<String>.self, from: defaultFilterData)
// swiftlint:enable force_try
}

View file

@ -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<String>? {
get {
guard let data = self[.updatedFilter] as Data? else {
return nil
}
return try? JSONDecoder().decode(BloomFilter<String>.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<T>(index: Item) -> T? {
get { userDefaults.value(forKey: index.rawValue) as? T }
set { userDefaults.set(newValue, forKey: index.rawValue) }
}
}

View file

@ -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<String>(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))
}
}

View file

@ -26,7 +26,7 @@ public extension AddIdentityViewModel {
let instanceURL: URL let instanceURL: URL
do { do {
try instanceURL = urlFieldText.url() instanceURL = try checkedURL()
} catch { } catch {
alertItem = AlertItem(error: error) alertItem = AlertItem(error: error)
@ -37,6 +37,9 @@ public extension AddIdentityViewModel {
.collect() .collect()
.map { _ in (identityID, instanceURL) } .map { _ in (identityID, instanceURL) }
.flatMap(allIdentitiesService.createIdentity(id:instanceURL:)) .flatMap(allIdentitiesService.createIdentity(id:instanceURL:))
.mapError {
return $0
}
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents( .handleEvents(
@ -55,7 +58,7 @@ public extension AddIdentityViewModel {
let instanceURL: URL let instanceURL: URL
do { do {
try instanceURL = urlFieldText.url() instanceURL = try checkedURL()
} catch { } catch {
alertItem = AlertItem(error: error) alertItem = AlertItem(error: error)
@ -72,4 +75,33 @@ public extension AddIdentityViewModel {
} receiveValue: { _ in } } receiveValue: { _ in }
.store(in: &cancellables) .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
}
} }

View file

@ -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
}
}

View file

@ -31,6 +31,7 @@ struct AddIdentityView: View {
rootViewModel.newIdentitySelected(id: id) rootViewModel.newIdentitySelected(id: id)
} }
} }
.onAppear(perform: viewModel.refreshFilter)
} }
} }