From 034aabbfffa083c8a49d62395bbf2c2c0bde8970 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Mon, 9 Mar 2020 20:19:24 -0400 Subject: [PATCH] Add login/logout support --- Frameworks/Account/Account.swift | 4 + .../Account/Account.xcodeproj/project.pbxproj | 28 ++++ .../Account/Credentials/Credentials.swift | 1 + .../Credentials/URLRequest+RSWeb.swift | 7 +- .../Models/NewsBlurLoginResponse.swift | 26 ++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 72 +++++++++ .../NewsBlur/NewsBlurAccountDelegate.swift | 146 ++++++++++++++++++ iOS/Account/Account.storyboard | 2 +- .../NewsBlurAccountViewController.swift | 26 ++-- 9 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift create mode 100644 Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift create mode 100644 Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index f8bbaf8a8..891d1522e 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -243,6 +243,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment) case .feedWrangler: self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport) + case .newsBlur: + self.delegate = NewsBlurAccountDelegate(dataFolder: dataFolder, transport: transport) default: return nil } @@ -325,6 +327,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion) case .feedWrangler: FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) + case .newsBlur: + NewsBlurAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) default: break } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 0b927f0d0..abd406985 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; @@ -63,6 +64,8 @@ 552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */; }; 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */; }; 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */; }; + 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */; }; + 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */; }; 841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; }; 841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; }; 841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; }; @@ -220,6 +223,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -278,6 +282,8 @@ 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITagging.swift; sourceTree = ""; }; 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountDelegate.swift; sourceTree = ""; }; 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPICaller.swift; sourceTree = ""; }; + 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAPICaller.swift; sourceTree = ""; }; + 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAccountDelegate.swift; sourceTree = ""; }; 841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = ""; }; 841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = ""; }; 841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = ""; }; @@ -435,6 +441,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 179DBD810D353D9CED7C3BED /* Models */ = { + isa = PBXGroup; + children = ( + 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; 3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = { isa = PBXGroup; children = ( @@ -523,6 +537,16 @@ path = ReaderAPI; sourceTree = ""; }; + 769F2630AF8DC873D4A73567 /* NewsBlur */ = { + isa = PBXGroup; + children = ( + 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */, + 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */, + 179DBD810D353D9CED7C3BED /* Models */, + ); + path = NewsBlur; + sourceTree = ""; + }; 841973E91F6DD19E006346C4 /* Products */ = { isa = PBXGroup; children = ( @@ -622,6 +646,7 @@ 8469F80F1F6DC3C10084783E /* Frameworks */, D511EEB4202422BB00712EC3 /* xcconfig */, 848934FA1F62484F00CEBD24 /* Info.plist */, + 769F2630AF8DC873D4A73567 /* NewsBlur */, ); sourceTree = ""; usesTabs = 1; @@ -1107,6 +1132,9 @@ 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */, 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */, 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, + 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, + 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, + 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Account/Credentials/Credentials.swift index bc5ac86ee..9cb9ae474 100644 --- a/Frameworks/Account/Credentials/Credentials.swift +++ b/Frameworks/Account/Credentials/Credentials.swift @@ -17,6 +17,7 @@ public enum CredentialsType: String { case basic = "password" case feedWranglerBasic = "feedWranglerBasic" case feedWranglerToken = "feedWranglerToken" + case newsBlur = "newsBlur" case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" case oauthAccessToken = "oauthAccessToken" diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index 1717dc10f..c74b73a68 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -33,7 +33,12 @@ public extension URLRequest { ]) case .feedWranglerToken: self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) - case .readerBasic: + case .newsBlur: + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + httpMethod = "POST" + let postData = "username=\(credentials.username)&password=\(credentials.secret)" + httpBody = postData.data(using: String.Encoding.utf8) + case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" var postData = URLComponents() diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift new file mode 100644 index 000000000..9529ea8e0 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift @@ -0,0 +1,26 @@ +// +// NewsBlurLoginResponse.swift +// Account +// +// Created by Anh Quang Do on 2020-03-09. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct NewsBlurLoginResponse: Decodable { + var code: Int + var errors: LoginError? + + struct LoginError: Decodable { + var username: [String]? + var others: [String]? + } +} + +extension NewsBlurLoginResponse.LoginError { + private enum CodingKeys: String, CodingKey { + case username = "username" + case others = "__all__" + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift new file mode 100644 index 000000000..0bd0d65d6 --- /dev/null +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -0,0 +1,72 @@ +// +// NewsBlurAPICaller.swift +// Account +// +// Created by Anh-Quang Do on 3/9/20. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +enum NewsBlurError: LocalizedError { + case general(message: String) + + var errorDescription: String? { + switch self { + case .general(let message): + return message + } + } +} + +final class NewsBlurAPICaller: NSObject { + + private let baseURL = URL(string: "https://www.newsblur.com/")! + private var transport: Transport! + + var credentials: Credentials? + weak var accountMetadata: AccountMetadata? + + init(transport: Transport!) { + super.init() + self.transport = transport + } + + func validateCredentials(completion: @escaping (Result) -> Void) { + let url = baseURL.appendingPathComponent("api/login") + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: NewsBlurLoginResponse.self) { result in + switch result { + case .success(_, let payload): + guard payload?.code != -1 else { + let error = payload?.errors?.username ?? payload?.errors?.others + if let message = error?.first { + completion(.failure(NewsBlurError.general(message: message))) + } else { + completion(.failure(NewsBlurError.general(message: "Failed to log in"))) + } + return + } + completion(.success(self.credentials)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func logout(completion: @escaping (Result) -> Void) { + let url = baseURL.appendingPathComponent("api/logout") + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift new file mode 100644 index 000000000..ef169af21 --- /dev/null +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -0,0 +1,146 @@ +// +// NewsBlurAccountDelegate.swift +// Account +// +// Created by Anh-Quang Do on 3/9/20. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Articles +import RSCore +import RSDatabase +import RSParser +import RSWeb +import SyncDatabase +import os.log + +final class NewsBlurAccountDelegate: AccountDelegate { + + var behaviors: AccountBehaviors = [] + + var isOPMLImportInProgress: Bool = false + var server: String? = "newsblur.com" + var credentials: Credentials? { + didSet { + caller.credentials = credentials + } + } + + var accountMetadata: AccountMetadata? = nil + var refreshProgress = DownloadProgress(numberOfTasks: 0) + + private let caller: NewsBlurAPICaller + private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur") + private let database: SyncDatabase + + init(dataFolder: String, transport: Transport?) { + if let transport = transport { + caller = NewsBlurAPICaller(transport: transport) + } else { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 1 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + let session = URLSession(configuration: sessionConfiguration) + caller = NewsBlurAPICaller(transport: session) + } + + database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3")) + } + + func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func refreshArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> ()) { + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> ()) { + } + + func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + fatalError("markArticles(for:articles:statusKey:flag:) has not been implemented") + } + + func accountDidInitialize(_ account: Account) { + credentials = try? account.retrieveCredentials(type: .newsBlur) + } + + func accountWillBeDeleted(_ account: Account) { + caller.logout() { _ in } + } + + class func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result) -> ()) { + let caller = NewsBlurAPICaller(transport: transport) + caller.credentials = credentials + caller.validateCredentials() { result in + DispatchQueue.main.async { + completion(result) + } + } + } + + func suspendNetwork() { + } + + func suspendDatabase() { + database.suspend() + } + + func resume() { + database.resume() + } +} diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard index 62d36ff42..dd9869dee 100644 --- a/iOS/Account/Account.storyboard +++ b/iOS/Account/Account.storyboard @@ -439,7 +439,7 @@ - + diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift index 3334f16c7..7c190a604 100644 --- a/iOS/Account/NewsBlurAccountViewController.swift +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -14,7 +14,7 @@ class NewsBlurAccountViewController: UITableViewController { @IBOutlet weak var activityIndicator: UIActivityIndicatorView! @IBOutlet weak var cancelBarButtonItem: UIBarButtonItem! - @IBOutlet weak var emailTextField: UITextField! + @IBOutlet weak var usernameTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var showHideButton: UIButton! @IBOutlet weak var actionButton: UIButton! @@ -26,19 +26,19 @@ class NewsBlurAccountViewController: UITableViewController { super.viewDidLoad() activityIndicator.isHidden = true - emailTextField.delegate = self + usernameTextField.delegate = self passwordTextField.delegate = self if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) actionButton.isEnabled = true - emailTextField.text = credentials.username + usernameTextField.text = credentials.username passwordTextField.text = credentials.secret } else { actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) } - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: emailTextField) + NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: usernameTextField) NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField) tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") @@ -75,17 +75,19 @@ class NewsBlurAccountViewController: UITableViewController { @IBAction func action(_ sender: Any) { - guard let email = emailTextField.text, let password = passwordTextField.text else { - showError(NSLocalizedString("Username & password required.", comment: "Credentials Error")) + guard let username = usernameTextField.text else { + showError(NSLocalizedString("Username required.", comment: "Credentials Error")) return } + let password = passwordTextField.text ?? "" + startAnimatingActivityIndicator() disableNavigation() // When you fill in the email address via auto-complete it adds extra whitespace - let trimmedEmail = email.trimmingCharacters(in: .whitespaces) - let credentials = Credentials(type: .basic, username: trimmedEmail, secret: password) + let trimmedUsername = username.trimmingCharacters(in: .whitespaces) + let credentials = Credentials(type: .newsBlur, username: trimmedUsername, secret: password) Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in self.stopAnimtatingActivityIndicator() @@ -124,17 +126,17 @@ class NewsBlurAccountViewController: UITableViewController { self.showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")) } } else { - self.showError(NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error")) + self.showError(NSLocalizedString("Invalid username/password combination.", comment: "Credentials Error")) } - case .failure: - self.showError(NSLocalizedString("Network error. Try again later.", comment: "Credentials Error")) + case .failure(let error): + self.showError(error.localizedDescription) } } } @objc func textDidChange(_ note: Notification) { - actionButton.isEnabled = !(emailTextField.text?.isEmpty ?? false) && !(passwordTextField.text?.isEmpty ?? false) + actionButton.isEnabled = !(usernameTextField.text?.isEmpty ?? false) } private func showError(_ message: String) {