// // RedditFeedProvider.swift // Account // // Created by Maurice Parker on 5/2/20. // Copyright © 2020 Ranchero Software, LLC. All rights reserved. // import Foundation import os.log import OAuthSwift import Secrets import RSParser import RSWeb public enum RedditFeedProviderError: LocalizedError { case rateLimitExceeded case unknown public var localizedDescription: String { switch self { case .rateLimitExceeded: return NSLocalizedString("Reddit API rate limit has been exceeded. Please wait a short time and try again.", comment: "Rate Limit") case .unknown: return NSLocalizedString("A Reddit Feed Provider error has occurred.", comment: "Unknown error") } } } public enum RedditFeedType: Int { case home = 0 case popular = 1 case all = 2 case subreddit = 3 } public final class RedditFeedProvider: FeedProvider { var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "RedditFeedProvider") private static let homeURL = "https://www.reddit.com" private static let server = "www.reddit.com" private static let apiBase = "https://oauth.reddit.com" private static let userAgentHeaders = UserAgent.headers() as! [String: String] private static let pseudoSubreddits = [ "popular": NSLocalizedString("Popular", comment: "Popular"), "all": NSLocalizedString("All", comment: "All") ] public var username: String? private var oauthToken: String private var oauthRefreshToken: String private var oauthSwift: OAuth2Swift? private var client: OAuthSwiftClient? { return oauthSwift?.client } private var rateLimitRemaining: Int? private var rateLimitReset: Date? public convenience init?(username: String) { guard let tokenCredentials = try? CredentialsManager.retrieveCredentials(type: .oauthAccessToken, server: Self.server, username: username), let refreshTokenCredentials = try? CredentialsManager.retrieveCredentials(type: .oauthRefreshToken, server: Self.server, username: username) else { return nil } self.init(oauthToken: tokenCredentials.secret, oauthRefreshToken: refreshTokenCredentials.secret) self.username = username } init(oauthToken: String, oauthRefreshToken: String) { self.oauthToken = oauthToken self.oauthRefreshToken = oauthRefreshToken oauthSwift = Self.oauth2Swift oauthSwift!.client.credential.oauthToken = oauthToken oauthSwift!.client.credential.oauthRefreshToken = oauthRefreshToken } public func ability(_ urlComponents: URLComponents) -> FeedProviderAbility { guard urlComponents.host?.hasSuffix("reddit.com") ?? false else { return .none } if let username = urlComponents.user { if username == username { return .owner } else { return .none } } return .available } public func iconURL(_ urlComponents: URLComponents, completion: @escaping (Result) -> Void) { guard urlComponents.path.hasPrefix("/r/") else { completion(.failure(RedditFeedProviderError.unknown)) return } subreddit(urlComponents) { result in switch result { case .success(let subreddit): if let iconURL = subreddit.data?.iconURL, !iconURL.isEmpty { completion(.success(iconURL)) } else { completion(.failure(RedditFeedProviderError.unknown)) } case .failure(let error): completion(.failure(error)) } } } public func metaData(_ urlComponents: URLComponents, completion: @escaping (Result) -> Void) { let path = urlComponents.path // Reddit Home let splitPath = path.split(separator: "/") if path == "" || path == "/" || (splitPath.count == 1 && RedditSort(rawValue: String(splitPath[0])) != nil) { let name = NSLocalizedString("Reddit Home", comment: "Reddit Home") let metaData = FeedProviderFeedMetaData(name: name, homePageURL: Self.homeURL) completion(.success(metaData)) return } // Subreddits guard splitPath.count > 1, splitPath.count < 4, splitPath[0] == "r" else { completion(.failure(RedditFeedProviderError.unknown)) return } if splitPath.count == 3 && RedditSort(rawValue: String(splitPath[2])) == nil { completion(.failure(RedditFeedProviderError.unknown)) return } let homePageURL = "https://www.reddit.com/\(splitPath[0])/\(splitPath[1])" // Reddit Popular, Reddit All, etc... if let subredditName = Self.pseudoSubreddits[String(splitPath[1])] { let localized = NSLocalizedString("Reddit %@", comment: "Reddit") let name = NSString.localizedStringWithFormat(localized as NSString, subredditName) as String let metaData = FeedProviderFeedMetaData(name: name, homePageURL: homePageURL) completion(.success(metaData)) return } subreddit(urlComponents) { result in switch result { case .success(let subreddit): if let displayName = subreddit.data?.displayName { completion(.success(FeedProviderFeedMetaData(name: displayName, homePageURL: homePageURL))) } else { completion(.failure(RedditFeedProviderError.unknown)) } case .failure(let error): completion(.failure(error)) } } } public func refresh(_ webFeed: WebFeed, completion: @escaping (Result, Error>) -> Void) { guard let urlComponents = URLComponents(string: webFeed.url) else { completion(.failure(TwitterFeedProviderError.unknown)) return } let api: String if urlComponents.path.isEmpty { api = "/.json" } else { api = "\(urlComponents.path).json" } let splitPath = urlComponents.path.split(separator: "/") let identifySubreddit: Bool if splitPath.count > 1 { if Self.pseudoSubreddits.keys.contains(String(splitPath[1])) { identifySubreddit = true } else { identifySubreddit = !urlComponents.path.hasPrefix("/r/") } } else { identifySubreddit = true } fetch(api: api, parameters: [:], resultType: RedditLinkListing.self) { result in switch result { case .success(let linkListing): DispatchQueue.global(qos: .background).async { let parsedItems = self.makeParsedItems(webFeed.url, identifySubreddit, linkListing) DispatchQueue.main.async { completion(.success(parsedItems)) } } case .failure(let error): completion(.failure(error)) } } } public static func create(tokenSuccess: OAuthSwift.TokenSuccess, completion: @escaping (Result) -> Void) { let oauthToken = tokenSuccess.credential.oauthToken let oauthRefreshToken = tokenSuccess.credential.oauthRefreshToken let redditFeedProvider = RedditFeedProvider(oauthToken: oauthToken, oauthRefreshToken: oauthRefreshToken) redditFeedProvider.fetch(api: "/api/v1/me", resultType: RedditMe.self) { result in switch result { case .success(let user): guard let username = user.name else { completion(.failure(RedditFeedProviderError.unknown)) return } do { redditFeedProvider.username = username try storeCredentials(username: username, oauthToken: oauthToken, oauthRefreshToken: oauthRefreshToken) completion(.success(redditFeedProvider)) } catch { completion(.failure(error)) } case .failure(let error): completion(.failure(error)) } } } public static func buildURL(_ type: RedditFeedType, username: String?, subreddit: String?, sort: RedditSort) -> URL? { var components = URLComponents() components.scheme = "https" components.host = "www.reddit.com" switch type { case .home: guard let username = username else { return nil } components.user = username components.path = "/\(sort.rawValue)" case .popular: components.path = "/r/popular/\(sort.rawValue)" case .all: components.path = "/r/all/\(sort.rawValue)" case .subreddit: guard let subreddit = subreddit else { return nil } components.path = "/r/\(subreddit)/\(sort.rawValue)" } return components.url } } // MARK: OAuth1SwiftProvider extension RedditFeedProvider: OAuth2SwiftProvider { public static var oauth2Swift: OAuth2Swift { let oauth2 = OAuth2Swift(consumerKey: Secrets.redditConsumerKey, consumerSecret: "", authorizeUrl: "https://www.reddit.com/api/v1/authorize.compact?", accessTokenUrl: "https://www.reddit.com/api/v1/access_token", responseType: "token") oauth2.accessTokenBasicAuthentification = true return oauth2 } public static var callbackURL: URL { return URL(string: "netnewswire://success")! } public static var oauth2Vars: (state: String, scope: String, params: [String : String]) { let state = generateState(withLength: 20) let scope = "identity mysubreddits read" let params = [ "client_id" : Secrets.redditConsumerKey, "response_type" : "code", "state" : state, "redirect_uri" : "netnewswire://success", "duration" : "permanent", "scope" : scope ] return (state: state, scope: scope, params: params) } } private extension RedditFeedProvider { func subreddit(_ urlComponents: URLComponents, completion: @escaping (Result) -> Void) { let splitPath = urlComponents.path.split(separator: "/") guard splitPath.count > 1 else { completion(.failure(RedditFeedProviderError.unknown)) return } let secondElement = String(splitPath[1]) let api = "/r/\(secondElement)/about.json" fetch(api: api, parameters: [:], resultType: RedditSubreddit.self, completion: completion) } func fetch(api: String, parameters: [String: Any] = [:], resultType: R.Type, completion: @escaping (Result) -> Void) { guard let client = client else { completion(.failure(RedditFeedProviderError.unknown)) return } if let remaining = rateLimitRemaining, let reset = rateLimitReset, remaining < 1 && reset > Date() { completion(.failure(RedditFeedProviderError.rateLimitExceeded)) return } let url = "\(Self.apiBase)\(api)" var expandedParameters = parameters expandedParameters["raw_json"] = "1" client.get(url, parameters: expandedParameters, headers: Self.userAgentHeaders) { result in switch result { case .success(let response): if let remaining = response.response.value(forHTTPHeaderField: "X-Ratelimit-Remaining") { self.rateLimitRemaining = Int(remaining) } else { self.rateLimitRemaining = nil } if let reset = response.response.value(forHTTPHeaderField: "X-Ratelimit-Reset") { self.rateLimitReset = Date(timeIntervalSinceNow: Double(reset) ?? 0) } else { self.rateLimitReset = nil } DispatchQueue.global(qos: .background).async { let decoder = JSONDecoder() do { let result = try decoder.decode(resultType, from: response.data) DispatchQueue.main.async { completion(.success(result)) } } catch { DispatchQueue.main.async { completion(.failure(error)) } } } case .failure(let oathError): self.handleFailure(error: oathError) { error in if let error = error { completion(.failure(error)) } else { self.fetch(api: api, parameters: parameters, resultType: resultType, completion: completion) } } } } } func makeParsedItems(_ webFeedURL: String,_ identifySubreddit: Bool, _ linkListing: RedditLinkListing) -> Set { var parsedItems = Set() guard let linkDatas = linkListing.data?.children?.compactMap({ $0.data }), !linkDatas.isEmpty else { return parsedItems } for linkData in linkDatas { guard let permalink = linkData.permalink else { continue } let parsedItem = ParsedItem(syncServiceID: nil, uniqueID: permalink, feedURL: webFeedURL, url: "https://www.reddit.com\(permalink)", externalURL: linkData.url, title: linkData.title, language: nil, contentHTML: linkData.renderAsHTML(identifySubreddit: identifySubreddit), contentText: linkData.selfText, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: linkData.createdDate, dateModified: nil, authors: makeParsedAuthors(linkData.author), tags: nil, attachments: nil) parsedItems.insert(parsedItem) } return parsedItems } func makeParsedAuthors(_ username: String?) -> Set? { guard let username = username else { return nil } var urlComponents = URLComponents(string: "https://www.reddit.com") urlComponents?.path = "/u/\(username)" let userURL = urlComponents?.url?.absoluteString return Set([ParsedAuthor(name: "u/\(username)", url: userURL, avatarURL: nil, emailAddress: nil)]) } func handleFailure(error: OAuthSwiftError, completion: @escaping (Error?) -> Void) { if case .tokenExpired = error { os_log(.debug, log: self.log, "Access token expired, attempting to renew...") oauthSwift?.renewAccessToken(withRefreshToken: oauthRefreshToken) { [weak self] result in guard let strongSelf = self, let username = strongSelf.username else { completion(nil) return } switch result { case .success(let tokenSuccess): strongSelf.oauthToken = tokenSuccess.credential.oauthToken strongSelf.oauthRefreshToken = tokenSuccess.credential.oauthRefreshToken do { try Self.storeCredentials(username: username, oauthToken: strongSelf.oauthToken, oauthRefreshToken: strongSelf.oauthRefreshToken) os_log(.debug, log: strongSelf.log, "Access token renewed.") } catch { completion(error) return } completion(nil) case .failure(let oathError): completion(oathError) } } } else { completion(error) } } static func storeCredentials(username: String, oauthToken: String, oauthRefreshToken: String) throws { let tokenCredentials = Credentials(type: .oauthAccessToken, username: username, secret: oauthToken) try CredentialsManager.storeCredentials(tokenCredentials, server: Self.server) let tokenSecretCredentials = Credentials(type: .oauthRefreshToken, username: username, secret: oauthRefreshToken) try CredentialsManager.storeCredentials(tokenSecretCredentials, server: Self.server) } }