diff --git a/Account/Package.swift b/Account/Package.swift index bb97fb862..b0cd28359 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -22,6 +22,7 @@ let package = Package( .package(path: "../CloudKitExtras"), .package(path: "../ReaderAPI"), .package(path: "../CloudKitSync"), + .package(path: "../NewsBlur"), .package(path: "../CommonErrors") ], targets: [ @@ -38,6 +39,7 @@ let package = Package( "Core", "CloudKitExtras", "ReaderAPI", + "NewsBlur", "CloudKitSync", "CommonErrors" ], diff --git a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate+Internal.swift similarity index 99% rename from Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift rename to Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate+Internal.swift index b97ceb49b..211b69659 100644 --- a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate+Internal.swift @@ -14,6 +14,7 @@ import Web import SyncDatabase import os.log import Core +import NewsBlur extension NewsBlurAccountDelegate { diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift index 413a8cced..37cb5385d 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -13,6 +13,7 @@ import Web import SyncDatabase import os.log import Secrets +import NewsBlur final class NewsBlurAccountDelegate: AccountDelegate { diff --git a/Account/Sources/Account/URLRequest+Account.swift b/Account/Sources/Account/URLRequest+Account.swift index 29de76daa..0be36b699 100755 --- a/Account/Sources/Account/URLRequest+Account.swift +++ b/Account/Sources/Account/URLRequest+Account.swift @@ -11,15 +11,15 @@ import Web import Secrets public extension URLRequest { - + init(url: URL, credentials: Credentials?, conditionalGet: HTTPConditionalGetInfo? = nil) { - + self.init(url: url) - + guard let credentials = credentials else { return } - + switch credentials.type { case .basic: let data = "\(credentials.username):\(credentials.secret)".data(using: .utf8) @@ -39,8 +39,8 @@ public extension URLRequest { setValue("\(NewsBlurAPICaller.SessionIdCookie)=\(credentials.secret)", forHTTPHeaderField: "Cookie") httpShouldHandleCookies = true case .readerBasic: - setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - httpMethod = "POST" + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + httpMethod = "POST" var postData = URLComponents() postData.queryItems = [ URLQueryItem(name: "Email", value: credentials.username), @@ -48,36 +48,23 @@ public extension URLRequest { ] httpBody = postData.enhancedPercentEncodedQuery?.data(using: .utf8) case .readerAPIKey: - let auth = "GoogleLogin auth=\(credentials.secret)" - setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + let auth = "GoogleLogin auth=\(credentials.secret)" + setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) case .oauthAccessToken: - let auth = "OAuth \(credentials.secret)" - setValue(auth, forHTTPHeaderField: "Authorization") + let auth = "OAuth \(credentials.secret)" + setValue(auth, forHTTPHeaderField: "Authorization") case .oauthAccessTokenSecret: - assertionFailure("Token secrets are used by OAuth1. Did you mean to use `OAuthSwift` instead of a URLRequest?") - break - case .oauthRefreshToken: - // While both access and refresh tokens are credentials, it seems the `Credentials` cases - // enumerates how the identity of the user can be proved rather than - // credentials-in-general, such as in this refresh token case, - // the authority to prove an identity. - assertionFailure("Refresh tokens are used to replace expired access tokens. Did you mean to use `accessToken` instead?") - break - } - - guard let conditionalGet = conditionalGet else { - return + assertionFailure("Token secrets are used by OAuth1. Did you mean to use `OAuthSwift` instead of a URLRequest?") + break + case .oauthRefreshToken: + // While both access and refresh tokens are credentials, it seems the `Credentials` cases + // enumerates how the identity of the user can be proved rather than + // credentials-in-general, such as in this refresh token case, + // the authority to prove an identity. + assertionFailure("Refresh tokens are used to replace expired access tokens. Did you mean to use `accessToken` instead?") + break } - - // Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those. - // TODO: drop this check in late 2037. - if let lastModified = conditionalGet.lastModified, !lastModified.contains("2038") { - setValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) - } - if let etag = conditionalGet.etag { - setValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) - } - + + conditionalGet?.addRequestHeadersToURLRequest(&self) } - } diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index e4fa709d3..b137aaa91 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -1465,6 +1465,7 @@ 84F9EAE1213660A100CF2DE4 /* testGenericScript.applescript */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.applescript; path = testGenericScript.applescript; sourceTree = ""; }; 84F9EAE2213660A100CF2DE4 /* establishMainWindowStartingState.applescript */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.applescript; path = establishMainWindowStartingState.applescript; sourceTree = ""; }; 84F9EAE4213660A100CF2DE4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 84FB9FAC2BC33AFE00B7AFC3 /* NewsBlur */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = NewsBlur; sourceTree = ""; }; 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = ""; }; B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+NetNewsWire.swift"; sourceTree = ""; }; B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = ""; }; @@ -2357,6 +2358,7 @@ 849C64611ED37A5D003D8FC0 /* Products */, 51C452B22265141B00C03939 /* Frameworks */, 51CD32C624D2DEF9009ABAEF /* Account */, + 84FB9FAC2BC33AFE00B7AFC3 /* NewsBlur */, 84CC98D92BC1DD25006A05C9 /* ReaderAPI */, 845F3D2B2BC268FE00AEBB68 /* CloudKitSync */, 8410C4A62BC221C900D4F799 /* CommonErrors */, diff --git a/NewsBlur/.gitignore b/NewsBlur/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/NewsBlur/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/NewsBlur/Package.swift b/NewsBlur/Package.swift new file mode 100644 index 000000000..77c0f843e --- /dev/null +++ b/NewsBlur/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "NewsBlur", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "NewsBlur", + targets: ["NewsBlur"]), + ], + dependencies: [ + .package(path: "../Web"), + .package(path: "../Secrets"), + .package(path: "../Parser"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "NewsBlur", + dependencies: [ + "Web", + "Parser", + "Secrets" + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "NewsBlurTests", + dependencies: ["NewsBlur"]), + ] +) diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurFeed.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurFeed.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurFeed.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurFeed.swift diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurFeedChange.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurFeedChange.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurFeedChange.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurFeedChange.swift diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurFolderChange.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurFolderChange.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurFolderChange.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurFolderChange.swift diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurGenericCodingKeys.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurGenericCodingKeys.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurGenericCodingKeys.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurGenericCodingKeys.swift diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurLoginResponse.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurLoginResponse.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurLoginResponse.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurLoginResponse.swift diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurStory.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurStory.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurStory.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurStory.swift diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurStoryHash.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurStoryHash.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurStoryHash.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurStoryHash.swift diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift b/NewsBlur/Sources/NewsBlur/Models/NewsBlurStoryStatusChange.swift similarity index 100% rename from Account/Sources/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift rename to NewsBlur/Sources/NewsBlur/Models/NewsBlurStoryStatusChange.swift diff --git a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller+Internal.swift similarity index 67% rename from Account/Sources/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift rename to NewsBlur/Sources/NewsBlur/NewsBlurAPICaller+Internal.swift index 7881e21f2..ef4f6b050 100644 --- a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift +++ b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller+Internal.swift @@ -44,7 +44,7 @@ extension NewsBlurAPICaller { } // GET endpoint - func requestData( + func requestData( endpoint: String, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, @@ -74,7 +74,7 @@ extension NewsBlurAPICaller { } // POST to endpoint - func sendUpdates( + func sendUpdates( endpoint: String, payload: NewsBlurDataConvertible, resultType: R.Type, @@ -108,25 +108,21 @@ extension NewsBlurAPICaller { return } - let request = URLRequest(url: callURL, credentials: credentials) + let request = URLRequest(url: callURL, newsBlurCredentials: credentials) - transport.send(request: request) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } + Task { @MainActor in - switch result { - case .success: + do { + try await transport.send(request: request) completion(.success(())) - case .failure(let error): + } catch { completion(.failure(error)) } } } // GET URL with params - func requestData( + func requestData( callURL: URL?, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, @@ -138,23 +134,20 @@ extension NewsBlurAPICaller { return } - let request = URLRequest(url: callURL, credentials: credentials) + let request = URLRequest(url: callURL, newsBlurCredentials: credentials) - transport.send( - request: request, - resultType: resultType, - dateDecoding: dateDecoding, - keyDecoding: keyDecoding - ) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } + Task { @MainActor in - switch result { - case .success(let response): + do { + let response = try await transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) + + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } completion(.success(response)) - case .failure(let error): + + } catch { completion(.failure(error)) } } @@ -162,42 +155,42 @@ extension NewsBlurAPICaller { // POST to URL with params, discard response func sendUpdates( - callURL: URL?, - payload: NewsBlurDataConvertible, - completion: @escaping (Result) -> Void + callURL: URL?, + payload: NewsBlurDataConvertible, + completion: @escaping (Result) -> Void ) { guard let callURL = callURL else { completion(.failure(TransportError.noURL)) return } - var request = URLRequest(url: callURL, credentials: credentials) + var request = URLRequest(url: callURL, newsBlurCredentials: credentials) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) request.httpBody = payload.asData - transport.send(request: request, method: HTTPMethod.post) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } + Task { @MainActor in - switch result { - case .success: + do { + try await transport.send(request: request, method: HTTPMethod.post) + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } completion(.success(())) - case .failure(let error): + } catch { completion(.failure(error)) } } } // POST to URL with params - func sendUpdates( - callURL: URL?, - payload: NewsBlurDataConvertible, - resultType: R.Type, - dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, - keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, - completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + func sendUpdates( + callURL: URL?, + payload: NewsBlurDataConvertible, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void ) { guard let callURL = callURL else { completion(.failure(TransportError.noURL)) @@ -209,26 +202,23 @@ extension NewsBlurAPICaller { return } - var request = URLRequest(url: callURL, credentials: credentials) + var request = URLRequest(url: callURL, newsBlurCredentials: credentials) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) - transport.send( - request: request, - method: HTTPMethod.post, - data: data, - resultType: resultType, - dateDecoding: dateDecoding, - keyDecoding: keyDecoding - ) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } + Task { @MainActor in + + do { + + let response = try await transport.send(request: request, method: HTTPMethod.post, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) + + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } - switch result { - case .success(let response): completion(.success(response)) - case .failure(let error): + + } catch { completion(.failure(error)) } } diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAPICaller.swift b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller.swift similarity index 98% rename from Account/Sources/Account/NewsBlur/NewsBlurAPICaller.swift rename to NewsBlur/Sources/NewsBlur/NewsBlurAPICaller.swift index c3e4bbfc4..e7e268e53 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller.swift @@ -10,7 +10,7 @@ import Foundation import Web import Secrets -final class NewsBlurAPICaller: NSObject { +@MainActor public final class NewsBlurAPICaller: NSObject { static let SessionIdCookie = "newsblur_sessionid" let baseURL = URL(string: "https://www.newsblur.com/")! @@ -18,7 +18,6 @@ final class NewsBlurAPICaller: NSObject { var suspended = false var credentials: Credentials? - weak var accountMetadata: AccountMetadata? init(transport: Transport!) { super.init() diff --git a/NewsBlur/Sources/NewsBlur/URLRequest+NewsBlur.swift b/NewsBlur/Sources/NewsBlur/URLRequest+NewsBlur.swift new file mode 100644 index 000000000..dc1094e4e --- /dev/null +++ b/NewsBlur/Sources/NewsBlur/URLRequest+NewsBlur.swift @@ -0,0 +1,45 @@ +// +// File.swift +// +// +// Created by Brent Simmons on 4/7/24. +// + +import Foundation +import Web +import Secrets + +public extension URLRequest { + + @MainActor init(url: URL, newsBlurCredentials: Credentials?, conditionalGet: HTTPConditionalGetInfo? = nil) { + + self.init(url: url) + + guard let credentials = newsBlurCredentials else { + return + } + + let credentialsType = credentials.type + precondition(credentialsType == .newsBlurBasic || credentialsType == .newsBlurSessionId) + + if credentialsType == .newsBlurBasic { + + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + httpMethod = "POST" + + var postData = URLComponents() + postData.queryItems = [ + URLQueryItem(name: "username", value: credentials.username), + URLQueryItem(name: "password", value: credentials.secret), + ] + httpBody = postData.enhancedPercentEncodedQuery?.data(using: .utf8) + + } else if credentialsType == .newsBlurSessionId { + + setValue("\(NewsBlurAPICaller.SessionIdCookie)=\(credentials.secret)", forHTTPHeaderField: "Cookie") + httpShouldHandleCookies = true + } + + conditionalGet?.addRequestHeadersToURLRequest(&self) + } +} diff --git a/NewsBlur/Tests/NewsBlurTests/NewsBlurTests.swift b/NewsBlur/Tests/NewsBlurTests/NewsBlurTests.swift new file mode 100644 index 000000000..6282fcec1 --- /dev/null +++ b/NewsBlur/Tests/NewsBlurTests/NewsBlurTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import NewsBlur + +final class NewsBlurTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/ReaderAPI/Sources/ReaderAPI/URLRequest+ReaderAPI.swift b/ReaderAPI/Sources/ReaderAPI/URLRequest+ReaderAPI.swift index eb73b0e9e..ad674b80a 100644 --- a/ReaderAPI/Sources/ReaderAPI/URLRequest+ReaderAPI.swift +++ b/ReaderAPI/Sources/ReaderAPI/URLRequest+ReaderAPI.swift @@ -39,17 +39,6 @@ extension URLRequest { setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) } - guard let conditionalGet = conditionalGet else { - return - } - - // Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those. - // TODO: drop this check in late 2037. - if let lastModified = conditionalGet.lastModified, !lastModified.contains("2038") { - setValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) - } - if let etag = conditionalGet.etag { - setValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) - } + conditionalGet?.addRequestHeadersToURLRequest(&self) } } diff --git a/Web/Sources/Web/HTTPConditionalGetInfo.swift b/Web/Sources/Web/HTTPConditionalGetInfo.swift index c62a382d4..cb61c6447 100755 --- a/Web/Sources/Web/HTTPConditionalGetInfo.swift +++ b/Web/Sources/Web/HTTPConditionalGetInfo.swift @@ -37,10 +37,10 @@ public struct HTTPConditionalGetInfo: Codable, Equatable { // Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those. // TODO: drop this check in late 2037. if let lastModified = lastModified, !lastModified.contains("2038") { - urlRequest.addValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) + urlRequest.setValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) } if let etag = etag { - urlRequest.addValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) + urlRequest.setValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) } } }