diff --git a/Account/Sources/Account/FeedProvider/Reddit/RedditFeedProvider.swift b/Account/Sources/Account/FeedProvider/Reddit/RedditFeedProvider.swift deleted file mode 100644 index 651825259..000000000 --- a/Account/Sources/Account/FeedProvider/Reddit/RedditFeedProvider.swift +++ /dev/null @@ -1,445 +0,0 @@ -// -// 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 RSCore -import RSParser -import RSWeb - -public enum RedditFeedProviderError: LocalizedError { - case rateLimitExceeded - case accessFailure(Error) - case unknown - - public var errorDescription: 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 .accessFailure(let error): - return NSLocalizedString("An attempt to access your Reddit feed(s) failed.\n\nIf this problem persists, please deactivate and reactivate the Reddit extension to fix this problem.\n\n\(error.localizedDescription)", comment: "Reddit Access") - 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, RedditFeedProviderTokenRefreshOperationDelegate { - - 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") - ] - - private let operationQueue = MainThreadOperationQueue() - private var parsingQueue = DispatchQueue(label: "RedditFeedProvider parse queue") - - public var username: String? - - var oauthTokenLastRefresh: Date? - var oauthToken: String - var oauthRefreshToken: String - - 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(RedditFeedProviderError.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): - self.parsingQueue.async { - let parsedItems = self.makeParsedItems(webFeed.url, identifySubreddit, linkListing) - DispatchQueue.main.async { - completion(.success(parsedItems)) - } - } - case .failure(let error): - if (error as? OAuthSwiftError)?.errorCode == -11 { - completion(.success(Set())) - } else { - completion(.failure(RedditFeedProviderError.accessFailure(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 - } - - 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) - } - -} - -// MARK: OAuth1SwiftProvider - -extension RedditFeedProvider: OAuth2SwiftProvider { - - public static var oauth2Swift: OAuth2Swift { - let oauth2 = OAuth2Swift(consumerKey: SecretsManager.provider.redditConsumerKey, - consumerSecret: "", - authorizeUrl: "https://www.reddit.com/api/v1/authorize.compact?", - accessTokenUrl: "https://www.reddit.com/api/v1/access_token", - responseType: "code") - 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 = [ - "duration" : "permanent", - ] - 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 - } - - self.parsingQueue.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 { - - let op = RedditFeedProviderTokenRefreshOperation(delegate: self) - - op.completionBlock = { operation in - let refreshOperation = operation as! RedditFeedProviderTokenRefreshOperation - if let error = refreshOperation.error { - completion(error) - } else { - completion(nil) - } - } - - operationQueue.add(op) - - } else { - completion(error) - } - } - -} diff --git a/Account/Sources/Account/FeedProvider/Reddit/RedditFeedProviderTokenRefreshOperation.swift b/Account/Sources/Account/FeedProvider/Reddit/RedditFeedProviderTokenRefreshOperation.swift deleted file mode 100644 index 6a90e9984..000000000 --- a/Account/Sources/Account/FeedProvider/Reddit/RedditFeedProviderTokenRefreshOperation.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// RedditFeedProviderTokenRefreshOperation.swift -// -// -// Created by Maurice Parker on 8/12/20. -// - -import Foundation -import os.log -import RSCore -import OAuthSwift -import Secrets - -protocol RedditFeedProviderTokenRefreshOperationDelegate: AnyObject { - var username: String? { get } - var oauthTokenLastRefresh: Date? { get set } - var oauthToken: String { get set } - var oauthRefreshToken: String { get set } - var oauthSwift: OAuth2Swift? { get } -} - -class RedditFeedProviderTokenRefreshOperation: MainThreadOperation { - - var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "RedditFeedProvider") - - public var isCanceled = false - public var id: Int? - public weak var operationDelegate: MainThreadOperationDelegate? - public var name: String? = "WebViewProviderReplenishQueueOperation" - public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? - - private weak var delegate: RedditFeedProviderTokenRefreshOperationDelegate? - - var error: Error? - - init(delegate: RedditFeedProviderTokenRefreshOperationDelegate) { - self.delegate = delegate - } - - func run() { - guard let delegate = delegate, let username = delegate.username else { - self.operationDelegate?.operationDidComplete(self) - return - } - - // If another operation has recently refreshed the token, we don't need to do it again - if let lastRefresh = delegate.oauthTokenLastRefresh, Date().timeIntervalSince(lastRefresh) < 120 { - self.operationDelegate?.operationDidComplete(self) - return - } - - os_log(.debug, log: self.log, "Access token expired, attempting to renew...") - - delegate.oauthSwift?.renewAccessToken(withRefreshToken: delegate.oauthRefreshToken) { [weak self] result in - guard let self = self else { return } - - switch result { - case .success(let tokenSuccess): - delegate.oauthToken = tokenSuccess.credential.oauthToken - delegate.oauthRefreshToken = tokenSuccess.credential.oauthRefreshToken - do { - try RedditFeedProvider.storeCredentials(username: username, oauthToken: delegate.oauthToken, oauthRefreshToken: delegate.oauthRefreshToken) - delegate.oauthTokenLastRefresh = Date() - os_log(.debug, log: self.log, "Access token renewed.") - } catch { - self.error = error - self.operationDelegate?.operationDidComplete(self) - } - self.operationDelegate?.operationDidComplete(self) - case .failure(let oathError): - self.error = oathError - self.operationDelegate?.operationDidComplete(self) - } - } - - } - -} diff --git a/Account/Sources/Account/FeedProvider/Reddit/RedditGalleryData.swift b/Account/Sources/Account/FeedProvider/Reddit/RedditGalleryData.swift deleted file mode 100644 index 821b88c80..000000000 --- a/Account/Sources/Account/FeedProvider/Reddit/RedditGalleryData.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// RedditGalleryData.swift -// Account -// -// Created by Maurice Parker on 7/27/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation - -struct RedditGalleryData: Codable { - - let items: [RedditGalleryDataItem]? - - enum CodingKeys: String, CodingKey { - case items = "items" - } - -} - -struct RedditGalleryDataItem: Codable { - - let mediaID: String? - - enum CodingKeys: String, CodingKey { - case mediaID = "media_id" - } - -} diff --git a/Account/Sources/Account/FeedProvider/Reddit/RedditLink.swift b/Account/Sources/Account/FeedProvider/Reddit/RedditLink.swift deleted file mode 100644 index bdbebfac0..000000000 --- a/Account/Sources/Account/FeedProvider/Reddit/RedditLink.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// RedditLink.swift -// Account -// -// Created by Maurice Parker on 5/4/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation - -final class RedditLink: Codable { - - let kind: String? - let data: RedditLinkData? - - enum CodingKeys: String, CodingKey { - case kind = "kind" - case data = "data" - } - -} - -final class RedditLinkData: Codable { - - let title: String? - let permalink: String? - let url: String? - let id: String? - let subredditNamePrefixed: String? - let selfHTML: String? - let selfText: String? - let postHint: String? - let author: String? - let created: Double? - let isVideo: Bool? - let media: RedditMedia? - let mediaEmbed: RedditMediaEmbed? - let preview: RedditPreview? - let crossPostParents: [RedditLinkData]? - let galleryData: RedditGalleryData? - let mediaMetadata: [String: RedditMediaMetadata]? - - enum CodingKeys: String, CodingKey { - case title = "title" - case permalink = "permalink" - case url = "url" - case id = "id" - case subredditNamePrefixed = "subreddit_name_prefixed" - case selfHTML = "selftext_html" - case selfText = "selftext" - case postHint = "post_hint" - case author = "author" - case created = "created_utc" - case isVideo = "is_video" - case media = "media" - case mediaEmbed = "media_embed" - case preview = "preview" - case crossPostParents = "crosspost_parent_list" - case galleryData = "gallery_data" - case mediaMetadata = "media_metadata" - } - - var createdDate: Date? { - guard let created = created else { return nil } - return Date(timeIntervalSince1970: created) - } - - func renderAsHTML(identifySubreddit: Bool) -> String { - var html = String() - - if identifySubreddit, let subredditNamePrefixed = subredditNamePrefixed { - html += "

\(subredditNamePrefixed)

" - } - - if let parent = crossPostParents?.first { - html += "
" - if let subreddit = parent.subredditNamePrefixed { - html += "

\(subreddit)

" - } - let parentHTML = parent.renderAsHTML(identifySubreddit: false) - if parentHTML.isEmpty { - html += renderURLAsHTML() - } else { - html += parentHTML - } - html += "
" - return html - } - - if let selfHTML = selfHTML { - html += selfHTML - } - html += renderURLAsHTML() - return html - } - - func renderURLAsHTML() -> String { - guard let url = url else { return "" } - - if url.hasSuffix(".gif") { - return "" - } - - if isVideo ?? false, let videoURL = media?.video?.hlsURL { - var html = "
" - return html - } - - if let imageVariantURL = preview?.images?.first?.variants?.mp4?.source?.url { - var html = "
" - return html - } - - if let videoPreviewURL = preview?.videoPreview?.url { - var html = "
" - return html - } - - if !url.hasPrefix("https://imgur.com"), let mediaEmbedContent = mediaEmbed?.content { - return mediaEmbedContent - } - - if let imageSource = preview?.images?.first?.source, let imageURL = imageSource.url { - var html = "
) -> Void) { switch extensionPointType { - case is RedditFeedProvider.Type: - if let tokenSuccess = tokenSuccess { - RedditFeedProvider.create(tokenSuccess: tokenSuccess) { result in - switch result { - case .success(let reddit): - completion(.success(reddit)) - case .failure(let error): - completion(.failure(error)) - } - } - } else { - completion(.failure(ExtensionPointManagerError.unableToCreate)) - } default: break } @@ -137,8 +120,6 @@ private extension ExtensionPointManager { func extensionPoint(for extensionPointID: ExtensionPointIdentifer) -> ExtensionPoint? { switch extensionPointID { - case .reddit(let username): - return RedditFeedProvider(username: username) #if os(macOS) default: return nil diff --git a/Shared/ExtensionPoints/RedditFeedProvider-Extensions.swift b/Shared/ExtensionPoints/RedditFeedProvider-Extensions.swift deleted file mode 100644 index 0f4731e01..000000000 --- a/Shared/ExtensionPoints/RedditFeedProvider-Extensions.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// RedditFeedProvider-Extensions.swift -// NetNewsWire -// -// Created by Maurice Parker on 5/2/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import Foundation -import Account - -extension RedditFeedProvider: ExtensionPoint { - - static var isSinglton = false - static var isDeveloperBuildRestricted = true - static var title = NSLocalizedString("Reddit", comment: "Reddit") - static var image = AppAssets.extensionPointReddit - static var description: NSAttributedString = { - return RedditFeedProvider.makeAttrString("This extension enables you to subscribe to Reddit URLs as if they were RSS feeds. It only works with \(Account.defaultLocalAccountName) or iCloud accounts.") - }() - - var extensionPointID: ExtensionPointIdentifer { - guard let username = username else { - fatalError() - } - return ExtensionPointIdentifer.reddit(username) - } - - var title: String { - guard let username = username else { - fatalError() - } - return "u/\(username)" - } - -}