├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── edwardhinkle.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ ├── xcshareddata │ └── xcschemes │ │ └── IndieWebKit.xcscheme │ └── xcuserdata │ ├── edwardhinkle.xcuserdatad │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ │ └── xcschememanagement.plist │ └── ehinkle-ad.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── .travis.yml ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── IndieWebKit │ ├── EndpointType.swift │ ├── HTTPMethod.swift │ ├── IndieAuth │ ├── AccessType.swift │ ├── IndieAuth.swift │ ├── IndieAuthError.swift │ ├── IndieAuthRequest.swift │ ├── ProfileDiscoveryRequest.swift │ └── SignInType.swift │ ├── Micropub │ ├── MicropubSession.swift │ └── models │ │ ├── ExternalFile.swift │ │ ├── MicropubActionType.swift │ │ ├── MicropubConfig.swift │ │ ├── MicropubDestination.swift │ │ ├── MicropubError.swift │ │ ├── MicropubPost.swift │ │ ├── MicropubPostType.swift │ │ ├── MicropubQueryType.swift │ │ ├── MicropubSendType.swift │ │ ├── MicropubVisibility.swift │ │ ├── PostType.swift │ │ ├── SupportedPostType.swift │ │ ├── SyndicationTarget.swift │ │ └── SyndicationTargetCard.swift │ ├── Microsub │ ├── Microsub.swift │ └── models │ │ ├── MicrosubAction.swift │ │ ├── MicrosubActionExtension.swift │ │ ├── MicrosubActionType.swift │ │ ├── MicrosubChannelEffectAction.swift │ │ ├── MicrosubChannelModifyAction.swift │ │ ├── MicrosubChannelReorderAction.swift │ │ ├── MicrosubError.swift │ │ ├── MicrosubPreviewAction.swift │ │ ├── MicrosubSearchAction.swift │ │ ├── MicrosubTimelineAction.swift │ │ └── MicrosubTimelineMethodType.swift │ ├── String.extension.swift │ └── UABuilder.swift └── Tests ├── IndieWebKitTests ├── IndieAuthTests.swift ├── MicropubTests.swift ├── MicrosubTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/edwardhinkle.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EdwardHinkle/IndieWebKit/2949b020e220580492c4b1ae9eb7da117a1a9f5f/.swiftpm/xcode/package.xcworkspace/xcuserdata/edwardhinkle.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/IndieWebKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 49 | 55 | 56 | 57 | 58 | 59 | 69 | 70 | 76 | 77 | 83 | 84 | 85 | 86 | 88 | 89 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/edwardhinkle.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/edwardhinkle.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | IndieWebKit.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | IndieWebKit 16 | 17 | primary 18 | 19 | 20 | IndieWebKitTests 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/ehinkle-ad.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/ehinkle-ad.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | IndieWebKit.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | IndieWebKit 16 | 17 | primary 18 | 19 | 20 | IndieWebKitTests 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: objective-c 2 | os: osx 3 | osx_image: xcode10.2 4 | xcode_scheme: IndieWebKit 5 | xcode_workspace: .swiftpm/xcode/package.xcworkspace 6 | install: 7 | - swift build 8 | notifications: 9 | email: 10 | on_success: change 11 | on_failure: change 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 edwardhinkle 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CryptoSwift", 6 | "repositoryURL": "https://github.com/krzyzanowskim/CryptoSwift.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "3a2acbb32ab68215ee1596ee6004da8e90c3721b", 10 | "version": "1.0.0" 11 | } 12 | }, 13 | { 14 | "package": "SwiftSoup", 15 | "repositoryURL": "https://github.com/scinfu/SwiftSoup.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "a2b316e9574c6904e08995290a975cb16693ac24", 19 | "version": "2.2.0" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "IndieWebKit", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v12), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 14 | .library( 15 | name: "IndieWebKit", 16 | targets: ["IndieWebKit"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.2.0"), 22 | .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", .upToNextMinor(from: "1.0.0")) 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 26 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 27 | .target( 28 | name: "IndieWebKit", 29 | dependencies: ["SwiftSoup", "CryptoSwift"]), 30 | .testTarget( 31 | name: "IndieWebKitTests", 32 | dependencies: ["IndieWebKit"]), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IndieWebKit 2 | ![Platform macOS | iOS | iPadOS | tvOS | watchOS ](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20iOS%20%7C%20iPad%20OS%20%7C%20tvOS%20%7C%20watchOS-orange.svg) 3 | [![SPM compatible](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://github.com/apple/swift-package-manager) 4 | ![License: MIT](https://img.shields.io/github/license/edwardhinkle/IndieWebKit.svg) 5 | [![Build Status](https://api.travis-ci.org/EdwardHinkle/IndieWebKit.svg?branch=master)](https://travis-ci.org/EdwardHinkle/IndieWebKit) 6 | [![Twitter](https://img.shields.io/badge/twitter-@eddiehinkle-blue.svg?style=flat)](http://twitter.com/eddiehinkle) 7 | 8 | A Swift Framework for IndieWeb compatible technologies (IndieAuth, Micropub, Microsub). THIS IS CURRENTLY IN DEVELOPMENT AND NOT READY FOR USE. 9 | 10 | ## Author 11 | [Eddie Hinkle](https://eddiehinkle.com) 12 | 13 | ## License 14 | IndieWebKit is available under the MIT license. See the LICENSE file for more info. 15 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/EndpointType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EndpointType.swift 3 | // 4 | // 5 | // Created by Edward Hinkle on 6/9/19. 6 | // 7 | 8 | import Foundation 9 | public enum EndpointType: String, CaseIterable { 10 | case authorization_endpoint 11 | case token_endpoint 12 | case micropub 13 | case microsub 14 | case webmention 15 | } 16 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPMethod.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public enum HTTPMethod: String { 10 | case POST 11 | case GET 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/IndieAuth/AccessType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/11/19. 6 | // 7 | 8 | import Foundation 9 | public enum AccessType: String { 10 | case Authentication = "id" 11 | case Authorization = "code" 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/IndieAuth/IndieAuth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndieAuth.swift 3 | // 4 | // 5 | // Created by Eddie Hinkle on 6/7/19. 6 | // 7 | 8 | import Foundation 9 | import Network 10 | 11 | public class IndieAuth { 12 | 13 | /// Convienance method to check if a given string is a valid IndieAuth profile 14 | /// 15 | /// - Parameter profile: a string representation of an IndieAuth url profile 16 | /// - Author: Eddie Hinkle 17 | static public func checkForValidProfile(_ profile: String) -> Bool { 18 | 19 | guard let profileUrl = URL(string: profile) else { 20 | // An IndieAuth profile is not valid if it's not a url 21 | return false; 22 | } 23 | 24 | return IndieAuth.checkForValidProfile(profileUrl); 25 | } 26 | 27 | /// Checks if a given url is valid for the purpose of representing an IndieAuth profile. 28 | /// 29 | /// Logic for a valid profile comes from ths spec here: https://indieauth.spec.indieweb.org/#user-profile-url 30 | /// 31 | /// - Parameter profile: an IndieAuth profile url 32 | /// - Author: Eddie Hinkle 33 | static public func checkForValidProfile(_ profile: URL) -> Bool { 34 | // If the url is so badly formed it can't be turned into components, it definitely isn't valid 35 | guard let profileComponents = URLComponents(url: profile, resolvingAgainstBaseURL: false) else { 36 | return false; 37 | } 38 | 39 | // Profile URLs MUST have either an https or http scheme 40 | guard profileComponents.scheme != nil && (profileComponents.scheme == "http" || profileComponents.scheme == "https") else { 41 | return false; 42 | } 43 | 44 | // MUST contain a path component (/ is a valid path), 45 | guard profileComponents.path != "" else { 46 | return false; 47 | } 48 | 49 | // MUST NOT contain single-dot or double-dot path segments 50 | guard !profileComponents.path.contains(".") && !profileComponents.path.contains("..") else { 51 | return false; 52 | } 53 | 54 | // MAY contain a query string component 55 | // I don't think we need to do anything special to check this 56 | 57 | // MUST NOT contain a fragment component 58 | guard profileComponents.fragment == nil else { 59 | return false; 60 | } 61 | 62 | // MUST NOT contain a username or password component 63 | guard profileComponents.user == nil && profileComponents.password == nil else { 64 | return false; 65 | } 66 | 67 | // MUST NOT contain a port. 68 | guard profileComponents.port == nil else { 69 | return false; 70 | } 71 | 72 | // Additionally, hostnames MUST be domain names and MUST NOT be ipv4 or ipv6 addresses. 73 | guard let hostname = profileComponents.host else { 74 | return false; 75 | } 76 | 77 | guard IPv4Address(hostname) == nil || hostname == "127.0.0.1" else { 78 | return false; 79 | } 80 | 81 | guard IPv6Address(hostname) == nil || hostname == "[::1]" else { 82 | return false; 83 | } 84 | 85 | // If we made it here, we must have a legit profile! 🙌 86 | return true; 87 | } 88 | 89 | /// Convienance method to normalize a given string and convert it into a Url for use as an IndieAuth Profile 90 | /// 91 | /// - Parameter profile: a string representation of an IndieAuth url profile 92 | /// - Author: Eddie Hinkle 93 | static public func normalizeProfileUrl(_ profile: String) -> URL? { 94 | 95 | guard let profileUrl = URL(string: profile) else { 96 | // An IndieAuth profile is not valid if it's not a url 97 | return nil; 98 | } 99 | 100 | return IndieAuth.normalizeProfileUrl(profileUrl); 101 | } 102 | 103 | /// Normalize the profile url based on IndieAuth spec 104 | /// 105 | /// Logic comes from https://indieauth.spec.indieweb.org/#url-canonicalization 106 | /// 107 | /// - Parameter profile: an IndieAuth profile url 108 | /// - Author: Eddie Hinkle 109 | static public func normalizeProfileUrl(_ profile: URL) -> URL? { 110 | // guard let sourceProfile = URLComponents(url: profile, resolvingAgainstBaseURL: true) else { 111 | // return nil; 112 | // } 113 | let normalizedProfile = profile 114 | 115 | // If no scheme exists, add http 116 | if profile.scheme == nil { 117 | // TODO: Add default http scheme 118 | } 119 | 120 | // TODO: Check for non-existent path and add "/" to the end 121 | // URLComponents.path doesn't seem to work because it is assumed to be a string 122 | 123 | return normalizedProfile 124 | } 125 | 126 | /// Fetch a user's IndieAuth profile url and discovery endpoints for signing in based how how we want to sign in 127 | /// 128 | /// Logic follows https://indieauth.spec.indieweb.org/#discovery-by-clients 129 | /// - Parameter profile: an IndieAuth profile url 130 | static public func discoverProfileEndpoints(from profile: URL) { 131 | 132 | 133 | // Check the link headers for rel 134 | 135 | 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/IndieAuth/IndieAuthError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndieAuthError.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/12/19. 6 | // 7 | 8 | import Foundation 9 | enum IndieAuthError: Error { 10 | case authenticationError(String) 11 | case authorizationError(String) 12 | case revocationError(String) 13 | } 14 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/IndieAuth/IndieAuthRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndieAuthRequest.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/11/19. 6 | // 7 | 8 | import Foundation 9 | import CryptoSwift 10 | //import AuthenticationServices 11 | 12 | public class IndieAuthRequest { 13 | 14 | private var responseType: AccessType 15 | private(set) internal var profile: URL 16 | private var authorizationEndpoint: URL 17 | private var tokenEndpoint: URL? 18 | private var clientId: URL 19 | private var redirectUri: URL 20 | private var state: String 21 | private(set) internal var scope: [String] 22 | private var codeChallenge: String? 23 | private let codeChallengeMethod = "S256" 24 | 25 | var url: URL? { 26 | var requestUrl = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) 27 | var queryItems: [URLQueryItem] = [] 28 | 29 | queryItems.append(URLQueryItem(name: "me", value: profile.absoluteString)) 30 | queryItems.append(URLQueryItem(name: "client_id", value: clientId.absoluteString)) 31 | queryItems.append(URLQueryItem(name: "redirect_uri", value: redirectUri.absoluteString)) 32 | queryItems.append(URLQueryItem(name: "state", value: state)) 33 | 34 | if scope.count > 0 { 35 | queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " "))) 36 | } 37 | 38 | queryItems.append(URLQueryItem(name: "response_type", value: responseType.rawValue)) 39 | 40 | if codeChallenge != nil { 41 | queryItems.append(URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod)) 42 | queryItems.append(URLQueryItem(name: "code_challenge", value: codeChallenge)) 43 | } 44 | 45 | requestUrl?.queryItems = queryItems 46 | 47 | return requestUrl?.url 48 | } 49 | 50 | 51 | init(_ responseType: AccessType, 52 | for profile: URL, 53 | at authorizationEndpoint: URL, 54 | with tokenEndpoint: URL? = nil, 55 | clientId: URL, 56 | redirectUri: URL, 57 | state: String, 58 | scope: [String] = [], 59 | codeChallenge: String? = nil) { 60 | 61 | self.responseType = responseType 62 | self.profile = profile 63 | self.authorizationEndpoint = authorizationEndpoint 64 | self.tokenEndpoint = tokenEndpoint 65 | self.clientId = clientId 66 | self.redirectUri = redirectUri 67 | self.state = state 68 | self.scope = scope 69 | self.codeChallenge = codeChallenge 70 | 71 | if self.codeChallenge == nil { 72 | self.codeChallenge = generateDefaultCodeChallenge() 73 | } 74 | } 75 | 76 | @available(iOS 12.0, macOS 10.15, *) 77 | func start(completion: @escaping ((URL?) -> ())) { 78 | guard url != nil else { 79 | // TODO: Throw some type of error 80 | return 81 | } 82 | 83 | // ASWebAuthenticationSession(url: url!, callbackURLScheme: nil) { [weak self] responseUrl, error in 84 | // guard error == nil else { 85 | // // TODO: Throw some type of error 86 | // return 87 | // } 88 | // 89 | // guard responseUrl != nil else { 90 | // // TODO: Throw some type of error 91 | // return 92 | // } 93 | // 94 | // let authorizationCode = self?.parseResponse(responseUrl!) 95 | // guard authorizationCode != nil else { 96 | // // TODO: Throw an error because authorization code should not be nil 97 | // return 98 | // } 99 | // 100 | // switch (self?.responseType) { 101 | // case .Authentication: 102 | // self?.verifyAuthenticationCode(authorizationCode!) { [weak self] codeVerified in 103 | // if (codeVerified) { 104 | // completion(self?.profile) 105 | // } else { 106 | // completion(nil) 107 | // } 108 | // } 109 | // case .Authorization: 110 | // self?.retrieveToken(authorizationCode!) { [weak self] token in 111 | // if (token != nil) { 112 | // completion(nil) // Need to figure out what to return 113 | // } else { 114 | // completion(nil) 115 | // } 116 | // } 117 | // case .none: 118 | // // TODO: ERROR! 119 | // print("ERROR!! No Response Type") 120 | // } 121 | // 122 | // }.start() 123 | } 124 | 125 | func parseResponse(_ responseUrl: URL) -> String { 126 | let responseComponents = URLComponents(url: responseUrl, resolvingAgainstBaseURL: false) 127 | var state = "" 128 | var code = "" 129 | 130 | responseComponents?.queryItems?.forEach { queryItem in 131 | if queryItem.name == "code", queryItem.value != nil { 132 | code = queryItem.value! 133 | } else if queryItem.name == "state", queryItem.value != nil { 134 | state = queryItem.value! 135 | } 136 | } 137 | 138 | guard state == self.state else { 139 | // TODO: Throw some error because state doesn't match 140 | return "" 141 | } 142 | 143 | return code 144 | } 145 | 146 | func verifyAuthenticationCode(_ code: String, completion: @escaping ((Bool) -> Void)) { 147 | 148 | do { 149 | let verificationRequest = try getVerificationRequest(with: code) 150 | 151 | URLSession.shared.dataTask(with: verificationRequest) { [weak self] body, response, error in 152 | guard error == nil else { 153 | // TODO: Throw error here 154 | return 155 | } 156 | 157 | // TODO: Check to make sure content type is application/json 158 | 159 | guard body != nil else { 160 | // TODO: throw error here 161 | return 162 | } 163 | 164 | let responseProfile = try! JSONDecoder().decode([String:URL].self, from: body!) 165 | 166 | completion(self!.confirmVerificationResponse(responseProfile)) 167 | 168 | }.resume() 169 | 170 | } catch { 171 | // TODO: Figure out how to report error 172 | completion(false) 173 | } 174 | } 175 | 176 | func retrieveToken(_ code: String, completion: @escaping ((String?) -> Void)) { 177 | 178 | // do { 179 | // let verificationRequest = try getTokenRequest(with: code) 180 | // 181 | // URLSession.shared.dataTask(with: verificationRequest) { [weak self] body, response, error in 182 | // guard error == nil else { 183 | // // TODO: Throw error here 184 | // return 185 | // } 186 | // 187 | // // TODO: Check to make sure content type is application/json 188 | // 189 | // guard body != nil else { 190 | // // TODO: throw error here 191 | // return 192 | // } 193 | // 194 | // let response = try! JSONDecoder().decode([String:URL].self, from: body!) 195 | // 196 | // completion(self!.confirmVerificationResponse(response)) 197 | // 198 | // }.resume() 199 | // 200 | // } catch { 201 | // // TODO: Figure out how to report error 202 | // completion(false) 203 | // } 204 | } 205 | 206 | func getVerificationRequest(with code: String) throws -> URLRequest { 207 | var request = URLRequest(url: authorizationEndpoint) 208 | request.httpMethod = "POST" 209 | request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 210 | request.addValue("application/json", forHTTPHeaderField: "Accept") 211 | 212 | var postBody = [ 213 | "code": code, 214 | "client_id": clientId.absoluteString, 215 | "redirect_uri": redirectUri.absoluteString 216 | ] 217 | 218 | if codeChallenge != nil { 219 | postBody["code_verifier"] = codeChallenge 220 | } 221 | 222 | try request.httpBody = JSONEncoder().encode(postBody) 223 | 224 | return request 225 | } 226 | 227 | func getTokenRequest(with code: String) throws -> URLRequest { 228 | guard tokenEndpoint != nil else { 229 | // TODO: Throw error! 230 | throw URLError(URLError.Code.badURL) 231 | } 232 | 233 | var request = URLRequest(url: tokenEndpoint!) 234 | request.httpMethod = "POST" 235 | request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 236 | request.addValue("application/json", forHTTPHeaderField: "Accept") 237 | 238 | var postBody = [ 239 | "grant_type": "authorization_code", 240 | "code": code, 241 | "client_id": clientId.absoluteString, 242 | "redirect_uri": redirectUri.absoluteString, 243 | "me": profile.absoluteString 244 | ] 245 | 246 | if codeChallenge != nil { 247 | postBody["code_verifier"] = codeChallenge 248 | } 249 | 250 | try request.httpBody = JSONEncoder().encode(postBody) 251 | 252 | return request 253 | } 254 | 255 | func confirmVerificationResponse(_ responseProfile: [String:URL]) -> Bool { 256 | guard responseProfile["me"] != nil else { 257 | return false 258 | } 259 | 260 | let meComponents = URLComponents(url: responseProfile["me"]!, resolvingAgainstBaseURL: false) 261 | let profileComponents = URLComponents(url: profile, resolvingAgainstBaseURL: false) 262 | 263 | let validProfile = meComponents?.host == profileComponents?.host 264 | 265 | if (validProfile) { 266 | profile = responseProfile["me"]! 267 | } 268 | 269 | return validProfile 270 | } 271 | 272 | func parseTokenResponse(_ response: Data) throws -> (String, String) { 273 | 274 | let responseDictionary = try! JSONDecoder().decode([String:String].self, from: response) 275 | 276 | guard responseDictionary["access_token"] != nil else { 277 | // TODO: throw error 278 | throw IndieAuthError.authorizationError("Missing access_token in Response") 279 | } 280 | 281 | guard responseDictionary["token_type"] != nil else { 282 | // TODO: throw error 283 | throw IndieAuthError.authorizationError("Missing token_type in Response") 284 | } 285 | 286 | guard responseDictionary["scope"] != nil else { 287 | // TODO: throw error 288 | throw IndieAuthError.authorizationError("Missing scope in Response") 289 | } 290 | 291 | guard responseDictionary["me"] != nil else { 292 | // TODO: throw error 293 | throw IndieAuthError.authorizationError("Missing me in Response") 294 | } 295 | 296 | guard let meUrl = URL(string: responseDictionary["me"]!) else { 297 | throw IndieAuthError.authorizationError("me isn't a value url") 298 | } 299 | 300 | guard meUrl.host == profile.host else { 301 | throw IndieAuthError.authorizationError("me is a different domain than original") 302 | } 303 | 304 | scope = responseDictionary["scope"]!.components(separatedBy: " ") 305 | 306 | guard scope.count > 0 else { 307 | throw IndieAuthError.authorizationError("no scopes returned") 308 | } 309 | 310 | // TODO: We need to make sure the profile breaks for spoofing 311 | profile = meUrl 312 | 313 | return (responseDictionary["token_type"]!, responseDictionary["access_token"]!) 314 | } 315 | 316 | private func generateDefaultCodeChallenge() -> String? { 317 | return Data(base64Encoded: String.randomString(length: 128).sha256())?.base64EncodedString() 318 | } 319 | 320 | func checkCodeChallenge(_ testChallenge: String) -> Bool { 321 | guard codeChallenge != nil else { 322 | return false 323 | } 324 | 325 | return codeChallenge! == testChallenge 326 | } 327 | 328 | public static func revokeToken(_ accessToken: String, for tokenEndpoint: URL, completion: @escaping (Bool) -> ()) { 329 | var request = URLRequest(url: tokenEndpoint) 330 | request.httpMethod = "POST" 331 | request.addValue(MicropubSendType.FormEncoded.rawValue, forHTTPHeaderField: "Content-Type") 332 | request.addValue(MicropubSendType.JSON.rawValue, forHTTPHeaderField: "Accept") 333 | request.httpBody = "action=revoke&token=\(accessToken)".data(using: .utf8, allowLossyConversion: false) 334 | 335 | URLSession.shared.dataTask(with: request) { body, response, error in 336 | guard error == nil else { 337 | // TODO: Add error 338 | completion(false) 339 | return 340 | } 341 | 342 | guard let httpResponse = response as? HTTPURLResponse else { 343 | // TODO: Add error 344 | completion(false) 345 | return 346 | } 347 | 348 | completion(httpResponse.statusCode == 200) 349 | } 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/IndieAuth/ProfileDiscoveryRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileDiscoveryRequest.swift 3 | // 4 | // 5 | // Created by Edward Hinkle on 6/8/19. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import SwiftSoup 11 | 12 | /// Fetch a user's IndieAuth profile url and discovery endpoints for signing in based how how we want to sign in 13 | /// 14 | /// This has to be a class rather than a struct because only classes can be delegates 15 | /// 16 | /// Logic follows https://indieauth.spec.indieweb.org/#discovery-by-clients 17 | public class ProfileDiscoveryRequest: NSObject, URLSessionTaskDelegate { 18 | 19 | private(set) public var profile: URL 20 | private(set) public var endpoints: [EndpointType: URL?] = [:] 21 | 22 | init(for profile: URL) { 23 | self.profile = profile 24 | } 25 | 26 | public func start(completion: @escaping (() -> ())) { 27 | // TODO: Is self being capture below causing a memory leak??? 28 | self.fetchSiteData { response, body in 29 | self.parseSiteData(response: response, body: body) 30 | completion() 31 | } 32 | } 33 | 34 | func parseSiteData(response: HTTPURLResponse, body: String?) { 35 | if response.allHeaderFields["Link"] != nil { 36 | let httpLinkHeadersString = response.allHeaderFields["Link"] as! String 37 | let httpLinkHeaders = httpLinkHeadersString.split(separator: ",") 38 | httpLinkHeaders.forEach { linkHeader in 39 | 40 | // The Link headers are somethign like this: ; rel=")", with: "", options: .regularExpression), // We want to remove any non-url characters 44 | let endpointUrl = URL(string: endpoint, relativeTo: self.profile) { 45 | 46 | EndpointType.allCases.forEach { endpointType in 47 | // Only use value if it is the FIRST instance of a predefined endpointType 48 | if self.endpoints[endpointType] == nil && linkHeader.contains("rel=\"\(endpointType)\"") { 49 | self.endpoints[endpointType] = endpointUrl 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | // TODO: We should check that the response type is HTML not JSON 57 | if body != nil { 58 | do { 59 | let profilePage: Document = try SwiftSoup.parse(body!) 60 | 61 | EndpointType.allCases.filter { self.endpoints[$0] == nil } 62 | .forEach { endpointType in 63 | 64 | if var endpoint = try? profilePage.select("link[rel=\"\(endpointType)\"]").first()?.attr("href") { 65 | if !endpoint.contains("http") { 66 | endpoint = "\(self.profile)\(endpoint)" 67 | } 68 | self.endpoints[endpointType] = URL(string: endpoint) 69 | } 70 | } 71 | 72 | 73 | } catch Exception.Error(_, let message) { 74 | print(message) 75 | } catch { 76 | print("error") 77 | } 78 | } 79 | 80 | // TODO: Check if there should be any parsing of values from JSON response types 81 | } 82 | 83 | private func fetchSiteData(completion: @escaping (HTTPURLResponse, String?) -> ()) { 84 | var request = URLRequest(url: self.profile) 85 | request.setValue(UAString(), forHTTPHeaderField: "User-Agent") 86 | 87 | // set up the session 88 | let config = URLSessionConfiguration.default 89 | let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) 90 | 91 | session.dataTask(with: request) { (data, response, error) in 92 | // check for any errors 93 | guard error == nil else { 94 | print("error calling GET on \(self.profile)") 95 | print(error ?? "No error present") 96 | return 97 | } 98 | 99 | // Check if endpoint is in the HTTP Header fields 100 | if let httpResponse = response as? HTTPURLResponse { 101 | var html: String? = nil 102 | if data != nil { 103 | html = String(data: data!, encoding: .utf8) 104 | } 105 | 106 | completion(httpResponse, html) 107 | } 108 | 109 | }.resume() 110 | } 111 | 112 | public func urlSession(_ session: URLSession, 113 | task: URLSessionTask, 114 | willPerformHTTPRedirection response: HTTPURLResponse, 115 | newRequest request: URLRequest, 116 | completionHandler: @escaping (URLRequest?) -> Void) { 117 | 118 | // This is a permenant redirect, and we need to track the new url 119 | if (response.statusCode == 301 || response.statusCode == 308) { 120 | if request.url != nil { 121 | profile = request.url! 122 | } 123 | } 124 | 125 | completionHandler(request) 126 | } 127 | 128 | static public func makeProfileDiscoveryRequest(for profile: URL, completion: @escaping ((ProfileDiscoveryRequest) -> ())) { 129 | let userDiscoveryRequest = ProfileDiscoveryRequest(for: profile) 130 | userDiscoveryRequest.start { 131 | completion(userDiscoveryRequest) 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/IndieAuth/SignInType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationType.swift 3 | // 4 | // 5 | // Created by Edward Hinkle on 6/8/19. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/MicropubSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubSession.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/12/19. 6 | // 7 | 8 | import Foundation 9 | 10 | public class MicropubSession { 11 | 12 | private var micropubEndpoint: URL 13 | private var accessToken: String 14 | 15 | init(to micropubEndpoint: URL, 16 | with accessToken: String) { 17 | 18 | self.micropubEndpoint = micropubEndpoint 19 | self.accessToken = accessToken 20 | } 21 | 22 | func sendMicropubPost(_ post: MicropubPost, as contentType: MicropubSendType, with action: MicropubActionType? = nil, completion: @escaping ((URL?) -> ())) throws { 23 | let request = try getMicropubRequest(for: post, as: contentType, with: action) 24 | 25 | URLSession.shared.dataTask(with: request) { [weak self] body, response, error in 26 | do { 27 | let postUrl = try self?.parseMicropubResponse(body: body, response: response as? HTTPURLResponse, error: error, with: action) 28 | // On success we always want to return a url 29 | // Some actions don't return a url, but if no error is thrown, it was successful 30 | // So if the url is nil, we use the original post url to indicate success 31 | // I might need to change this model going forward 32 | print("We returned without error?") 33 | completion(postUrl ?? post.url) 34 | } catch MicropubError.generalError(let error) { 35 | print("Error Catching Micropub Request \(error)") 36 | completion(nil) 37 | } catch MicropubError.serverError(let error) { 38 | print("MICROPUB SERVER ERROR: \(error)") 39 | completion(nil) 40 | } catch { 41 | print("Uncaught error") 42 | completion(nil) 43 | } 44 | }.resume() 45 | } 46 | 47 | func getMicropubRequest(for post: MicropubPost, as contentType: MicropubSendType, with action: MicropubActionType? = nil) throws -> URLRequest { 48 | var request = URLRequest(url: micropubEndpoint) 49 | request.httpMethod = "POST" 50 | request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 51 | request.addValue(contentType.rawValue, forHTTPHeaderField: "Content-Type") 52 | request.addValue("IndieWebKit", forHTTPHeaderField: "X-Powered-By") 53 | 54 | // TODO: Add proper error catching 55 | request.httpBody = try? post.output(as: contentType, with: action) 56 | return request 57 | } 58 | 59 | func parseMicropubResponse(body: Data?, response: HTTPURLResponse?, error: Error?, with action: MicropubActionType?) throws -> URL? { 60 | guard error == nil else { 61 | throw MicropubError.generalError(error!.localizedDescription) 62 | } 63 | 64 | guard response != nil else { 65 | throw MicropubError.generalError("URL Response is nil") 66 | } 67 | 68 | print("Status returned \(response!.statusCode)") 69 | 70 | guard response!.statusCode == 200 || response!.statusCode == 201 || response!.statusCode == 202 || response!.statusCode == 204 else { 71 | // TODO: is there a way to get the server's error here? 72 | if body != nil { 73 | throw MicropubError.serverError(statusCode: response!.statusCode, description: String(data: body!, encoding: .utf8) ?? "No body") 74 | } 75 | throw MicropubError.serverError(statusCode: response!.statusCode, description: "Server returned a response that was not 200") 76 | } 77 | 78 | if action == nil, let postUrlString = response!.allHeaderFields["Location"] as? String { 79 | return URL(string: postUrlString) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // MARK: Configuration Query 86 | func getConfigQuery(completion: @escaping ((MicropubConfig?) -> ())) throws { 87 | let request = try getConfigurationRequest() 88 | 89 | URLSession.shared.dataTask(with: request) { [weak self] body, response, error in 90 | do { 91 | let config = try self?.parseConfigResponse(body: body, response: response, error: error) 92 | completion(config) 93 | } catch MicropubError.generalError(let error) { 94 | print("Error Catching Config Request \(error)") 95 | completion(nil) 96 | } catch { 97 | print("Uncaught error") 98 | completion(nil) 99 | } 100 | }.resume() 101 | } 102 | 103 | func parseConfigResponse(body: Data?, response: URLResponse?, error: Error?) throws -> MicropubConfig { 104 | guard body != nil else { 105 | throw MicropubError.generalError("Micropub Config Request didn't return anything") 106 | } 107 | 108 | guard error == nil else { 109 | throw MicropubError.generalError(error!.localizedDescription) 110 | } 111 | 112 | do { 113 | let config = try JSONDecoder().decode(MicropubConfig.self, from: body!) 114 | return config 115 | } catch DecodingError.keyNotFound(let missingKey, _) { 116 | throw MicropubError.generalError("Micropub Config missing \(missingKey.stringValue) key") 117 | } catch { 118 | throw MicropubError.generalError("There was an error trying to decode the server response") 119 | } 120 | } 121 | 122 | func getConfigurationRequest() throws -> URLRequest { 123 | guard var configRequestUrl = URLComponents(url: micropubEndpoint, resolvingAgainstBaseURL: false) else { 124 | throw MicropubError.generalError("Config Query Url Malformed") 125 | } 126 | 127 | configRequestUrl.queryItems = [ 128 | URLQueryItem(name: "q", value: "config") 129 | ] 130 | 131 | var request = URLRequest(url: configRequestUrl.url!) 132 | request.httpMethod = "GET" 133 | request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 134 | request.addValue("application/json", forHTTPHeaderField: "Accept") 135 | return request 136 | } 137 | 138 | // MARK: Syndication Target Query 139 | func getSyndicationTargetQuery(completion: @escaping (([SyndicationTarget]?) -> ())) throws { 140 | let request = try getSyndicationTargetRequest() 141 | 142 | URLSession.shared.dataTask(with: request) { [weak self] body, response, error in 143 | do { 144 | let syndicationTargets = try self?.parseSyndicationTargetResponse(body: body, response: response, error: error) 145 | completion(syndicationTargets) 146 | } catch MicropubError.generalError(let error) { 147 | print("Error Catching Syndication Target Request \(error)") 148 | completion(nil) 149 | } catch { 150 | print("Uncaught error") 151 | completion(nil) 152 | } 153 | }.resume() 154 | } 155 | 156 | func parseSyndicationTargetResponse(body: Data?, response: URLResponse?, error: Error?) throws -> [SyndicationTarget] { 157 | guard body != nil else { 158 | throw MicropubError.generalError("Micropub Syndication Target Request didn't return anything") 159 | } 160 | 161 | guard error == nil else { 162 | throw MicropubError.generalError(error!.localizedDescription) 163 | } 164 | 165 | do { 166 | let config = try JSONDecoder().decode(MicropubConfig.self, from: body!) 167 | return config.syndicateTo ?? [] 168 | } catch DecodingError.keyNotFound(let missingKey, _) { 169 | throw MicropubError.generalError("Micropub Config missing \(missingKey.stringValue) key") 170 | } catch { 171 | throw MicropubError.generalError("There was an error trying to decode the server response") 172 | } 173 | } 174 | 175 | func getSyndicationTargetRequest() throws -> URLRequest { 176 | guard var configRequestUrl = URLComponents(url: micropubEndpoint, resolvingAgainstBaseURL: false) else { 177 | throw MicropubError.generalError("Config Query Url Malformed") 178 | } 179 | 180 | configRequestUrl.queryItems = [ 181 | URLQueryItem(name: "q", value: MicropubQueryType.syndicateTo.rawValue) 182 | ] 183 | 184 | var request = URLRequest(url: configRequestUrl.url!) 185 | request.httpMethod = "GET" 186 | request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 187 | request.addValue("application/json", forHTTPHeaderField: "Accept") 188 | return request 189 | } 190 | 191 | // MARK: Category Query 192 | func getCategoryList(completion: @escaping (([String]?) -> ())) throws { 193 | let request = try getCategoryListRequest() 194 | 195 | URLSession.shared.dataTask(with: request) { [weak self] body, response, error in 196 | do { 197 | let categories = try self?.parseCategoryListResponse(body: body, response: response, error: error) 198 | completion(categories) 199 | } catch MicropubError.generalError(let error) { 200 | print("Error Catching Categories List Request \(error)") 201 | completion(nil) 202 | } catch { 203 | print("Uncaught error") 204 | completion(nil) 205 | } 206 | }.resume() 207 | } 208 | 209 | func parseCategoryListResponse(body: Data?, response: URLResponse?, error: Error?) throws -> [String] { 210 | guard body != nil else { 211 | throw MicropubError.generalError("Micropub Categories List Request didn't return anything") 212 | } 213 | 214 | guard error == nil else { 215 | throw MicropubError.generalError(error!.localizedDescription) 216 | } 217 | 218 | do { 219 | let categoryResponse = try JSONDecoder().decode([String:[String]].self, from: body!) 220 | 221 | if let categories = categoryResponse[MicropubQueryType.category.rawValue] { 222 | return categories 223 | } 224 | 225 | return[] 226 | } catch DecodingError.keyNotFound(let missingKey, _) { 227 | throw MicropubError.generalError("Micropub Config missing \(missingKey.stringValue) key") 228 | } catch { 229 | throw MicropubError.generalError("There was an error trying to decode the server response") 230 | } 231 | } 232 | 233 | func getCategoryListRequest() throws -> URLRequest { 234 | guard var configRequestUrl = URLComponents(url: micropubEndpoint, resolvingAgainstBaseURL: false) else { 235 | throw MicropubError.generalError("Config Query Url Malformed") 236 | } 237 | 238 | configRequestUrl.queryItems = [ 239 | URLQueryItem(name: "q", value: MicropubQueryType.category.rawValue) 240 | ] 241 | 242 | var request = URLRequest(url: configRequestUrl.url!) 243 | request.httpMethod = "GET" 244 | request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 245 | request.addValue("application/json", forHTTPHeaderField: "Accept") 246 | return request 247 | } 248 | 249 | // MARK: Source Query 250 | func getSourceQuery(for post: MicropubPost, with properties: [MicropubPost.PropertiesKeys]? = nil, completion: @escaping (([MicropubPost]?) -> ())) throws { 251 | let request = try getSourceRequest(for: post, with: properties) 252 | 253 | URLSession.shared.dataTask(with: request) { [weak self] body, response, error in 254 | do { 255 | let post = try self?.parseSourceResponse(body: body, response: response, error: error) 256 | completion(post) 257 | } catch MicropubError.generalError(let error) { 258 | print("Error Catching Source Request \(error)") 259 | completion(nil) 260 | } catch { 261 | print("Uncaught error") 262 | completion(nil) 263 | } 264 | }.resume() 265 | } 266 | 267 | func parseSourceResponse(body: Data?, response: URLResponse?, error: Error?) throws -> [MicropubPost] { 268 | guard body != nil else { 269 | throw MicropubError.generalError("Micropub Source Request didn't return anything") 270 | } 271 | 272 | guard error == nil else { 273 | throw MicropubError.generalError(error!.localizedDescription) 274 | } 275 | 276 | do { 277 | let post = try JSONDecoder().decode(MicropubPost.self, from: body!) 278 | return [post] 279 | } catch DecodingError.keyNotFound(let missingKey, _) { 280 | throw MicropubError.generalError("Micropub source missing \(missingKey.stringValue) key") 281 | } catch let error { 282 | throw MicropubError.generalError("There was an error trying to decode the server response: \(error)") 283 | } 284 | } 285 | 286 | func getSourceRequest(for post: MicropubPost? = nil, with properties: [MicropubPost.PropertiesKeys]? = nil) throws -> URLRequest { 287 | guard var configRequestUrl = URLComponents(url: micropubEndpoint, resolvingAgainstBaseURL: false) else { 288 | throw MicropubError.generalError("Config Query Url Malformed") 289 | } 290 | 291 | configRequestUrl.queryItems = [ 292 | URLQueryItem(name: "q", value: MicropubQueryType.source.rawValue) 293 | ] 294 | 295 | if post != nil { 296 | configRequestUrl.queryItems?.append(URLQueryItem(name: "url", value: post!.url?.absoluteString)) 297 | } 298 | 299 | if properties != nil { 300 | if properties!.count == 1 { 301 | configRequestUrl.queryItems?.append(URLQueryItem(name: "properties", value: properties![0].rawValue)) 302 | } else if properties!.count > 1 { 303 | for property in properties! { 304 | configRequestUrl.queryItems?.append(URLQueryItem(name: "properties", value: property.rawValue)) 305 | } 306 | } 307 | } 308 | 309 | var request = URLRequest(url: configRequestUrl.url!) 310 | request.httpMethod = "GET" 311 | request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 312 | request.addValue("application/json", forHTTPHeaderField: "Accept") 313 | return request 314 | } 315 | 316 | // func getVerificationRequest(with code: String) throws -> URLRequest { 317 | // var request = URLRequest(url: authorizationEndpoint) 318 | // request.httpMethod = "POST" 319 | // request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 320 | // request.addValue("application/json", forHTTPHeaderField: "Accept") 321 | // 322 | // var postBody = [ 323 | // "code": code, 324 | // "client_id": clientId.absoluteString, 325 | // "redirect_uri": redirectUri.absoluteString 326 | // ] 327 | // 328 | // if codeChallenge != nil { 329 | // postBody["code_verifier"] = codeChallenge 330 | // } 331 | // 332 | // try request.httpBody = JSONEncoder().encode(postBody) 333 | // 334 | // return request 335 | // } 336 | 337 | // func getTokenRequest(with code: String) throws -> URLRequest { 338 | // guard tokenEndpoint != nil else { 339 | // // TODO: Throw error! 340 | // throw URLError(URLError.Code.badURL) 341 | // } 342 | // 343 | // var request = URLRequest(url: tokenEndpoint!) 344 | // request.httpMethod = "POST" 345 | // request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 346 | // request.addValue("application/json", forHTTPHeaderField: "Accept") 347 | // 348 | // var postBody = [ 349 | // "grant_type": "authorization_code", 350 | // "code": code, 351 | // "client_id": clientId.absoluteString, 352 | // "redirect_uri": redirectUri.absoluteString, 353 | // "me": profile.absoluteString 354 | // ] 355 | // 356 | // if codeChallenge != nil { 357 | // postBody["code_verifier"] = codeChallenge 358 | // } 359 | // 360 | // try request.httpBody = JSONEncoder().encode(postBody) 361 | // 362 | // return request 363 | // } 364 | 365 | // func parseTokenResponse(_ response: Data) throws -> (String, String) { 366 | // 367 | // let responseDictionary = try! JSONDecoder().decode([String:String].self, from: response) 368 | // 369 | // guard responseDictionary["access_token"] != nil else { 370 | // // TODO: throw error 371 | // throw IndieAuthError.authorizationError("Missing access_token in Response") 372 | // } 373 | // 374 | // guard responseDictionary["token_type"] != nil else { 375 | // // TODO: throw error 376 | // throw IndieAuthError.authorizationError("Missing token_type in Response") 377 | // } 378 | // 379 | // guard responseDictionary["scope"] != nil else { 380 | // // TODO: throw error 381 | // throw IndieAuthError.authorizationError("Missing scope in Response") 382 | // } 383 | // 384 | // guard responseDictionary["me"] != nil else { 385 | // // TODO: throw error 386 | // throw IndieAuthError.authorizationError("Missing me in Response") 387 | // } 388 | // 389 | // guard let meUrl = URL(string: responseDictionary["me"]!) else { 390 | // throw IndieAuthError.authorizationError("me isn't a value url") 391 | // } 392 | // 393 | // guard meUrl.host == profile.host else { 394 | // throw IndieAuthError.authorizationError("me is a different domain than original") 395 | // } 396 | // 397 | // scope = responseDictionary["scope"]!.components(separatedBy: " ") 398 | // 399 | // guard scope.count > 0 else { 400 | // throw IndieAuthError.authorizationError("no scopes returned") 401 | // } 402 | // 403 | // // TODO: We need to make sure the profile breaks for spoofing 404 | // profile = meUrl 405 | // 406 | // return (responseDictionary["token_type"]!, responseDictionary["access_token"]!) 407 | // } 408 | } 409 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/ExternalFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExternalFile.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/18/19. 6 | // 7 | 8 | import Foundation 9 | public struct ExternalFile: Codable { 10 | var value: URL 11 | var alt: String? 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubActionType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubActionType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/14/19. 6 | // 7 | 8 | import Foundation 9 | public enum MicropubActionType: String { 10 | case delete 11 | case undelete 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubConfig.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | 10 | // This is the value returned from the Micropub configuration query endpoint. 11 | // See https://micropub.net/draft/#configuration for more information 12 | public struct MicropubConfig: Codable { 13 | let mediaEndpoint: URL? 14 | let syndicateTo: [SyndicationTarget]? 15 | let postTypes: [SupportedPostType]? // This supports the Supported Vocabulary extension. See https://github.com/indieweb/micropub-extensions/issues/1 16 | let q: [MicropubQueryType]? // This supports the Supported Queries extension. See https://github.com/indieweb/micropub-extensions/issues/7 17 | let destination: [MicropubDestination]? // This support the destinations extension 18 | 19 | enum CodingKeys: String, CodingKey { 20 | case mediaEndpoint = "media-endpoint" 21 | case syndicateTo = "syndicate-to" 22 | case postTypes = "post-types" 23 | case q 24 | case destination 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubDestination.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubDestination.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicropubDestination: Codable { 10 | let uid: String 11 | let name: String 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubError.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | enum MicropubError: Error { 10 | case generalError(String) 11 | case serverError(statusCode: Int, description: String) 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubPost.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/14/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicropubPost: Codable { 10 | var type: MicropubPostType? 11 | var url: URL? 12 | var content: String? 13 | var htmlContent: String? // Requires JSON 14 | var categories: [String]? 15 | var externalPhoto: [ExternalFile]? 16 | var externalVideo: [ExternalFile]? 17 | var externalAudio: [ExternalFile]? 18 | var syndicateTo: [SyndicationTarget]? 19 | var visibility: MicropubVisibility? 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case type 23 | case properties 24 | } 25 | 26 | enum PropertiesKeys: String, CodingKey { 27 | case url 28 | case content 29 | case categories = "category" 30 | case externalPhoto = "photo" 31 | case externalVideo = "video" 32 | case externalAudio = "audio" 33 | case syndicateTo = "mp-syndicate-to" 34 | case visibility 35 | } 36 | 37 | public init(type: MicropubPostType?, 38 | visibility: MicropubVisibility? = MicropubVisibility.isPublic, 39 | url: URL? = nil, 40 | content: String? = nil, 41 | htmlContent: String? = nil, 42 | categories: [String]? = nil, 43 | externalPhoto: [ExternalFile]? = nil, 44 | externalVideo: [ExternalFile]? = nil, 45 | externalAudio: [ExternalFile]? = nil, 46 | syndicateTo: [SyndicationTarget]? = nil) { 47 | 48 | self.type = type 49 | self.visibility = visibility 50 | self.url = url 51 | self.content = content 52 | self.htmlContent = htmlContent 53 | self.categories = categories 54 | self.externalPhoto = externalPhoto 55 | self.externalVideo = externalVideo 56 | self.externalAudio = externalAudio 57 | self.syndicateTo = syndicateTo 58 | } 59 | 60 | public init(from decoder: Decoder) throws { 61 | 62 | let values = try decoder.container(keyedBy: CodingKeys.self) 63 | type = try values.decode([MicropubPostType].self, forKey: .type)[0] 64 | 65 | let properties = try values.nestedContainer(keyedBy: PropertiesKeys.self, forKey: .properties) 66 | 67 | url = try? properties.decode([URL].self, forKey: .url)[0] 68 | 69 | htmlContent = try? properties.decode([[String: String]].self, forKey: .content)[0]["html"] 70 | content = try? properties.decode([String].self, forKey: .content)[0] 71 | 72 | categories = try? properties.decode([String].self, forKey: .categories) 73 | externalPhoto = nil 74 | externalVideo = nil 75 | externalAudio = nil 76 | syndicateTo = nil 77 | visibility = try? properties.decode([MicropubVisibility].self, forKey: .visibility)[0] 78 | 79 | // guard type != nil else { 80 | // throw MicropubError.generalError("Missing h-type!") 81 | // } 82 | // 83 | // var container = encoder.container(keyedBy: CodingKeys.self) 84 | // try container.encode(["h-\(type!)"], forKey: .type) 85 | // 86 | // var properties = container.nestedContainer(keyedBy: PropertiesKeys.self, forKey: .properties) 87 | // if url != nil { 88 | // try properties.encode([url], forKey: .url) 89 | // } 90 | // 91 | // if htmlContent != nil { 92 | // try properties.encode([["html": htmlContent!]], forKey: .content) 93 | // } else if content != nil { 94 | // try properties.encode([content], forKey: .content) 95 | // } 96 | // 97 | // if categories != nil { 98 | // try properties.encode(categories, forKey: .categories) 99 | // } 100 | // 101 | // if externalPhoto != nil { 102 | // try properties.encode(externalPhoto, forKey: .externalPhoto) 103 | // } 104 | // 105 | // if externalVideo != nil { 106 | // try properties.encode(externalVideo, forKey: .externalVideo) 107 | // } 108 | // 109 | // if externalAudio != nil { 110 | // try properties.encode(externalAudio, forKey: .externalAudio) 111 | // } 112 | // 113 | // if syndicateTo != nil { 114 | // try properties.encode(syndicateTo, forKey: .syndicateTo) 115 | // } 116 | // 117 | // if visibility != nil { 118 | // try properties.encode(visibility, forKey: .visibility) 119 | // } 120 | } 121 | 122 | public func encode(to encoder: Encoder) throws { 123 | 124 | guard type != nil else { 125 | throw MicropubError.generalError("Missing h-type!") 126 | } 127 | 128 | var container = encoder.container(keyedBy: CodingKeys.self) 129 | try container.encode([type], forKey: .type) 130 | 131 | var properties = container.nestedContainer(keyedBy: PropertiesKeys.self, forKey: .properties) 132 | if url != nil { 133 | try properties.encode([url], forKey: .url) 134 | } 135 | 136 | if htmlContent != nil { 137 | try properties.encode([["html": htmlContent!]], forKey: .content) 138 | } else if content != nil { 139 | try properties.encode([content], forKey: .content) 140 | } 141 | 142 | if categories != nil { 143 | try properties.encode(categories, forKey: .categories) 144 | } 145 | 146 | if externalPhoto != nil { 147 | try properties.encode(externalPhoto, forKey: .externalPhoto) 148 | } 149 | 150 | if externalVideo != nil { 151 | try properties.encode(externalVideo, forKey: .externalVideo) 152 | } 153 | 154 | if externalAudio != nil { 155 | try properties.encode(externalAudio, forKey: .externalAudio) 156 | } 157 | 158 | if syndicateTo != nil { 159 | try properties.encode(syndicateTo, forKey: .syndicateTo) 160 | } 161 | 162 | if visibility != nil { 163 | try properties.encode(visibility, forKey: .visibility) 164 | } 165 | } 166 | 167 | public func output(as type: MicropubSendType, with action: MicropubActionType?) throws -> Data? { 168 | 169 | switch type { 170 | case .FormEncoded: 171 | var postBody: [String] = [] 172 | 173 | switch action { 174 | case .some(let activeAction): 175 | postBody.append(createFormEncodedEntry(name: "action", value: activeAction.rawValue)) 176 | 177 | guard self.url != nil else { 178 | throw MicropubError.generalError("Trying to send Micropub request \(activeAction.rawValue) without a url property") 179 | } 180 | 181 | switch activeAction { 182 | case .delete: fallthrough 183 | case .undelete: 184 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.url.rawValue, value: url!.absoluteString)) 185 | } 186 | default: 187 | if self.type != nil { 188 | // The name on this item has to be a string of "h" because the CodingKey is the json version ("type") 189 | postBody.append(createFormEncodedEntry(name: "h", value: self.type!.rawValue)) 190 | } 191 | if self.content != nil { 192 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.content.rawValue, value: content!)) 193 | } 194 | if self.url != nil { 195 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.url.rawValue, value: url!.absoluteString)) 196 | } 197 | if self.categories != nil { 198 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.categories.rawValue, value: categories!)) 199 | } 200 | if self.externalPhoto != nil { 201 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.externalPhoto.rawValue, value: externalPhoto!.map { $0.value.absoluteString })) 202 | } 203 | if self.externalAudio != nil { 204 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.externalAudio.rawValue, value: externalAudio!.map { $0.value.absoluteString })) 205 | } 206 | if self.externalVideo != nil { 207 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.externalVideo.rawValue, value: externalVideo!.map { $0.value.absoluteString })) 208 | } 209 | if self.syndicateTo != nil { 210 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.syndicateTo.rawValue, value: syndicateTo!.map { $0.uid })) 211 | } 212 | if self.visibility != nil { 213 | postBody.append(createFormEncodedEntry(name: PropertiesKeys.visibility.rawValue, value: visibility!.rawValue)) 214 | } 215 | } 216 | 217 | return postBody.joined(separator: "&").data(using: .utf8, allowLossyConversion: false) 218 | case .Multipart: 219 | // TODO: Need to build a multipart data function 220 | return Data() 221 | case .JSON: 222 | return try? JSONEncoder().encode(self) 223 | } 224 | 225 | } 226 | 227 | func createFormEncodedEntry(name: String, value: String) -> String { 228 | if let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { 229 | return "\(name)=\(encodedValue)" 230 | } 231 | return "" 232 | } 233 | 234 | func createFormEncodedEntry(name: String, value: [String]) -> String { 235 | return value.map { singleValue in 236 | if let encodedValue = singleValue.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { 237 | if value.count == 1 { 238 | return "\(name)=\(encodedValue)" 239 | } 240 | return "\(name)[]=\(encodedValue)" 241 | } 242 | return "" 243 | }.joined(separator: "&") 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubPostType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubPostType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/17/19. 6 | // 7 | 8 | import Foundation 9 | public enum MicropubPostType: String, Codable { 10 | case entry = "h-entry" 11 | case event = "h-event" 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubQueryType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubQueryType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | 10 | // This defines the types of Micropub queries that this framework knows about.. 11 | // For more information, see: https://indieweb.org/Micropub-extensions 12 | public enum MicropubQueryType: String, Codable { 13 | case source 14 | case syndicateTo = "syndicate-to" 15 | case category 16 | case contact 17 | } 18 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubSendType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubSendType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/14/19. 6 | // 7 | 8 | import Foundation 9 | public enum MicropubSendType: String { 10 | case FormEncoded = "application/x-www-form-urlencoded" 11 | case Multipart = "multipart/form-data" 12 | case JSON = "application/json" 13 | } 14 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/MicropubVisibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicropubVisibility.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/18/19. 6 | // 7 | 8 | import Foundation 9 | public enum MicropubVisibility: String, Codable { 10 | case isPublic = "public" // Public is a protected keyword in swift 11 | case isPrivate = "private" // private is a protected keyword in swift 12 | case isUnlisted = "unlisted" 13 | case isProtected = "protected" 14 | } 15 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/PostType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | 10 | // The enum value becomes the "type" of supported post type 11 | public enum PostType: String, Codable { 12 | case note 13 | case article 14 | case photo 15 | case video 16 | case reply 17 | case like 18 | case repost 19 | case rsvp 20 | case bookmark 21 | } 22 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/SupportedPostType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupportedPostType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | public struct SupportedPostType: Codable { 10 | let type: PostType 11 | let name: String 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/SyndicationTarget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyndicationTarget.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | public struct SyndicationTarget: Codable { 10 | let uid: String 11 | let name: String 12 | let service: SyndicationTargetCard? 13 | let user: SyndicationTargetCard? 14 | } 15 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Micropub/models/SyndicationTargetCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyndicationTargetCard.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/13/19. 6 | // 7 | 8 | import Foundation 9 | public struct SyndicationTargetCard: Codable { 10 | let name: String 11 | let url: URL? 12 | let photo: URL? 13 | } 14 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/Microsub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/7/19. 6 | // 7 | 8 | import Foundation 9 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubAction.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public protocol MicrosubAction: Codable { 10 | func generateRequest(for endpoint: URL, with token: String) throws -> URLRequest 11 | func httpMethodForRequest() -> HTTPMethod 12 | func convertToPostBody() -> Data? 13 | } 14 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubActionExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubActionExtension.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | extension MicrosubAction { 10 | public func generateRequest(for microsubEndpoint: URL, with accessToken: String) throws -> URLRequest { 11 | var request = URLRequest(url: microsubEndpoint) 12 | request.httpMethod = self.httpMethodForRequest().rawValue 13 | request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 14 | request.addValue(MicropubSendType.FormEncoded.rawValue, forHTTPHeaderField: "Content-Type") 15 | request.addValue("IndieWebKit", forHTTPHeaderField: "X-Powered-By") 16 | 17 | let postBody = self.convertToPostBody() 18 | 19 | guard postBody != nil else { 20 | throw MicrosubError.generalError("Microsub Action couldn't be converted into a post body") 21 | } 22 | request.httpBody = postBody 23 | return request 24 | } 25 | 26 | public func createFormEncodedEntry(name: String, value: String) -> String { 27 | if let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { 28 | return "\(name)=\(encodedValue)" 29 | } 30 | return "" 31 | } 32 | 33 | public func createFormEncodedEntry(name: String, value: [String]) -> String { 34 | return value.map { singleValue in 35 | if let encodedValue = singleValue.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { 36 | if value.count == 1 { 37 | return "\(name)=\(encodedValue)" 38 | } 39 | return "\(name)[]=\(encodedValue)" 40 | } 41 | return "" 42 | }.joined(separator: "&") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubActionType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubActionType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public enum MicrosubActionType: String, Codable { 10 | case follow 11 | case unfollow 12 | case mute 13 | case unmute 14 | case block 15 | } 16 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubChannelEffectAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubChannelAction.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicrosubChannelEffectAction: MicrosubAction { 10 | var action: MicrosubActionType 11 | var channel: String 12 | var url: URL? = nil 13 | 14 | public func httpMethodForRequest() -> HTTPMethod { 15 | if url == nil { 16 | return .GET 17 | } 18 | 19 | return .POST 20 | } 21 | 22 | public func convertToPostBody() -> Data? { 23 | var postBody: [String] = [] 24 | 25 | postBody.append(createFormEncodedEntry(name: "action", value: action.rawValue)) 26 | postBody.append(createFormEncodedEntry(name: "channel", value: channel)) 27 | 28 | if url != nil { 29 | postBody.append(createFormEncodedEntry(name: "url", value: url!.absoluteString)) 30 | } 31 | 32 | return postBody.joined(separator: "&").data(using: .utf8, allowLossyConversion: false) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubChannelModifyAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubChannelModifyAction.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicrosubChannelAction: MicrosubAction { 10 | let action = "channels" 11 | var name: String? 12 | var channel: String? 13 | var method: String? 14 | 15 | init() { 16 | self.name = nil 17 | self.channel = nil 18 | self.method = nil 19 | } 20 | 21 | init(create name: String) { 22 | self.name = name 23 | self.channel = nil 24 | self.method = nil 25 | } 26 | 27 | init(update channel: String, with name: String) { 28 | self.name = name 29 | self.channel = channel 30 | self.method = nil 31 | } 32 | 33 | init(delete channel: String) { 34 | self.name = nil 35 | self.channel = channel 36 | self.method = "delete" 37 | } 38 | 39 | public func httpMethodForRequest() -> HTTPMethod { 40 | if self.name == nil, 41 | self.channel == nil, 42 | self.method == nil { 43 | 44 | return .GET 45 | } 46 | 47 | return .POST 48 | } 49 | 50 | public func convertToPostBody() -> Data? { 51 | var postBody: [String] = [] 52 | 53 | postBody.append(createFormEncodedEntry(name: "action", value: action)) 54 | 55 | if name != nil { 56 | postBody.append(createFormEncodedEntry(name: "name", value: name!)) 57 | } 58 | 59 | if channel != nil { 60 | postBody.append(createFormEncodedEntry(name: "channel", value: channel!)) 61 | } 62 | 63 | if method != nil { 64 | postBody.append(createFormEncodedEntry(name: "method", value: method!)) 65 | } 66 | 67 | return postBody.joined(separator: "&").data(using: .utf8, allowLossyConversion: false) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubChannelReorderAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubChannelReorderAction.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicrosubChannelReorderAction: MicrosubAction { 10 | let action = "channels" 11 | let method = "order" 12 | var channels: [String] 13 | 14 | public func httpMethodForRequest() -> HTTPMethod { 15 | return .POST 16 | } 17 | 18 | public func convertToPostBody() -> Data? { 19 | var postBody: [String] = [] 20 | 21 | postBody.append(createFormEncodedEntry(name: "action", value: action)) 22 | postBody.append(createFormEncodedEntry(name: "method", value: method)) 23 | postBody.append(createFormEncodedEntry(name: "channels", value: channels)) 24 | 25 | return postBody.joined(separator: "&").data(using: .utf8, allowLossyConversion: false) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubError.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | enum MicrosubError: Error { 10 | case generalError(String) 11 | case serverError(statusCode: Int, description: String) 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubPreviewAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubPreviewAction.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicrosubPreviewAction: MicrosubAction { 10 | let action = "preview" 11 | var url: URL 12 | 13 | public func httpMethodForRequest() -> HTTPMethod { 14 | return .POST 15 | } 16 | 17 | public func convertToPostBody() -> Data? { 18 | var postBody: [String] = [] 19 | 20 | postBody.append(createFormEncodedEntry(name: "action", value: action)) 21 | postBody.append(createFormEncodedEntry(name: "url", value: url.absoluteString)) 22 | 23 | return postBody.joined(separator: "&").data(using: .utf8, allowLossyConversion: false) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubSearchAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubSearchAction.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicrosubSearchAction: MicrosubAction { 10 | let action = "search" 11 | var query: String 12 | 13 | public func httpMethodForRequest() -> HTTPMethod { 14 | return .POST 15 | } 16 | 17 | public func convertToPostBody() -> Data? { 18 | var postBody: [String] = [] 19 | 20 | postBody.append(createFormEncodedEntry(name: "action", value: action)) 21 | postBody.append(createFormEncodedEntry(name: "query", value: query)) 22 | 23 | return postBody.joined(separator: "&").data(using: .utf8, allowLossyConversion: false) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubTimelineAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public struct MicrosubTimelineAction: MicrosubAction { 10 | let action = "timeline" 11 | var method: MicrosubTimelineMethodType 12 | var channel: String 13 | var entries: [String]? 14 | var lastReadEntry: String? 15 | 16 | public init(with method: MicrosubTimelineMethodType, for channel: String, on entries: [String]) { 17 | self.method = method 18 | self.channel = channel 19 | self.entries = entries 20 | self.lastReadEntry = nil 21 | } 22 | 23 | public init(markAsReadIn channel: String, before entry: String) { 24 | self.method = MicrosubTimelineMethodType.markRead 25 | self.channel = channel 26 | self.lastReadEntry = entry 27 | self.entries = nil 28 | } 29 | 30 | public func httpMethodForRequest() -> HTTPMethod { 31 | return .POST 32 | } 33 | 34 | public func convertToPostBody() -> Data? { 35 | var postBody: [String] = [] 36 | 37 | postBody.append(createFormEncodedEntry(name: "action", value: action)) 38 | postBody.append(createFormEncodedEntry(name: "method", value: method.rawValue)) 39 | postBody.append(createFormEncodedEntry(name: "channel", value: channel)) 40 | 41 | if entries != nil { 42 | postBody.append(createFormEncodedEntry(name: "entry", value: entries!)) 43 | } 44 | 45 | if lastReadEntry != nil { 46 | postBody.append(createFormEncodedEntry(name: "last_read_entry", value: lastReadEntry!)) 47 | } 48 | 49 | return postBody.joined(separator: "&").data(using: .utf8, allowLossyConversion: false) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/Microsub/models/MicrosubTimelineMethodType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MicrosubTimelineMethodType.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/19/19. 6 | // 7 | 8 | import Foundation 9 | public enum MicrosubTimelineMethodType: String, Codable { 10 | case markRead = "mark_read" 11 | case markUnread = "mark_unread" 12 | case remove = "remove" 13 | } 14 | -------------------------------------------------------------------------------- /Sources/IndieWebKit/String.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by ehinkle-ad on 6/11/19. 6 | // 7 | 8 | import Foundation 9 | extension String { 10 | public static func randomString(length: Int) -> String { 11 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~" 12 | return randomString(length: length, from: letters) 13 | } 14 | 15 | public static func randomAlphaNumericString(length: Int) -> String { 16 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 17 | return randomString(length: length, from: letters) 18 | } 19 | 20 | public static func randomString(length: Int, from stringOptions: String) -> String { 21 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~" 22 | return String((0.. String { 12 | var sysinfo = utsname() 13 | uname(&sysinfo) 14 | let dv = String(bytes: Data(bytes: &sysinfo.release, count: Int(_SYS_NAMELEN)), encoding: .ascii)!.trimmingCharacters(in: .controlCharacters) 15 | return "Darwin/\(dv)" 16 | } 17 | //eg. CFNetwork/808.3 18 | func CFNetworkVersion() -> String { 19 | let dictionary = Bundle(identifier: "com.apple.CFNetwork")?.infoDictionary! 20 | let version = dictionary?["CFBundleShortVersionString"] as! String 21 | return "CFNetwork/\(version)" 22 | } 23 | 24 | //eg. iOS/10_1 25 | func deviceVersion() -> String { 26 | // let currentDevice = UIDevice.current 27 | // return "\(currentDevice.systemName)/\(currentDevice.systemVersion)" 28 | // TODO: At some point, I need to find a way to get device info here 29 | return "Apple" 30 | } 31 | 32 | //eg. MyApp/1 33 | func appNameAndVersion() -> String { 34 | let dictionary = Bundle.main.infoDictionary! 35 | let version = dictionary["CFBundleShortVersionString"] 36 | let name = dictionary["CFBundleName"] 37 | 38 | guard name != nil && version != nil else { 39 | return "IndieWebKit" 40 | } 41 | 42 | return "\(name ?? "")/\(version ?? "")" 43 | } 44 | 45 | func UAString() -> String { 46 | return "\(appNameAndVersion()) \(deviceVersion()) \(CFNetworkVersion()) \(DarwinVersion())" 47 | } 48 | -------------------------------------------------------------------------------- /Tests/IndieWebKitTests/IndieAuthTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import IndieWebKit 3 | 4 | final class IndieAuthTests: XCTestCase { 5 | 6 | func testValidProfileUrls() { 7 | let valid_profile_1 = "https://example.com/" 8 | let valid_profile_2 = "https://example.com/username" 9 | let valid_profile_3 = "https://example.com/users?id=100" 10 | let valid_profile_4 = "http://127.0.0.1/"; 11 | let valid_profile_5 = "http://[::1]/"; 12 | 13 | 14 | XCTAssertEqual(IndieAuth.checkForValidProfile(valid_profile_1), true) 15 | XCTAssertEqual(IndieAuth.checkForValidProfile(valid_profile_2), true) 16 | XCTAssertEqual(IndieAuth.checkForValidProfile(valid_profile_3), true) 17 | XCTAssertEqual(IndieAuth.checkForValidProfile(valid_profile_4), true) 18 | XCTAssertEqual(IndieAuth.checkForValidProfile(valid_profile_5), true) 19 | } 20 | 21 | func testInvalidProfileUrls() { 22 | let invalid_profile_1 = "example.com" // missing scheme 23 | let invalid_profile_2 = "mailto:user@example.com" // invalid scheme 24 | let invalid_profile_3 = "https://example.com/foo/../bar" // contains a double-dot path 25 | let invalid_profile_4 = "https://example.com/#me" // contains a gragment 26 | let invalid_profile_5 = "https://user:pass@example.com/" // contains username and password 27 | let invalid_profile_6 = "https://172.28.92.51/" // host is an IPv4 address 28 | let invalid_profile_7 = "https://2001:0db8:85a3:0000:0000:8a2e:0370:7334/" // host is an IPv6 Address 29 | let invalid_profile_8 = "https://example.com/foo/./bar" // contains a single-dot path 30 | 31 | 32 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_1), false) 33 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_2), false) 34 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_3), false) 35 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_4), false) 36 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_5), false) 37 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_6), false) 38 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_7), false) 39 | XCTAssertEqual(IndieAuth.checkForValidProfile(invalid_profile_8), false) 40 | } 41 | 42 | // TODO: Uncomment normalize hostname test when that function is build 43 | // func testNormalizeHostnameUrl() { 44 | // let hostnameOnly = "example.com" 45 | // let hostnameWithScheme = "https://example.com" 46 | // let hostnameWithPath = "example.com/" 47 | // 48 | // XCTAssertEqual(IndieAuth.normalizeProfileUrl(hostnameOnly), URL(string: "http://example.com/")) 49 | // XCTAssertEqual(IndieAuth.normalizeProfileUrl(hostnameWithScheme), URL(string: "https://example.com/")) 50 | // XCTAssertEqual(IndieAuth.normalizeProfileUrl(hostnameWithPath), URL(string: "http://example.com/")) 51 | // } 52 | 53 | func testProfileDiscoveryRedirection() { 54 | let urlWithRedirect = URL(string: "https://aaronpk.com/")! // This url will redirect using a 301 55 | let urlWithoutRedirect = URL(string: "https://eddiehinkle.com/")! // This url should remain the same 56 | 57 | let expectationWithRedirect = self.expectation(description: "ProfileDiscoveryRequestWithRedirect") 58 | let discoveryWithRedirect = ProfileDiscoveryRequest(for: urlWithRedirect) 59 | discoveryWithRedirect.start { 60 | print("Check profile url after discovery request: \(discoveryWithRedirect.profile)") 61 | XCTAssertEqual(discoveryWithRedirect.profile, URL(string: "https://aaronparecki.com/")!) 62 | expectationWithRedirect.fulfill() 63 | } 64 | 65 | let expectationWithoutRedirect = self.expectation(description: "ProfileDiscoveryRequestWithoutRedirect") 66 | let discoveryWithoutRedirect = ProfileDiscoveryRequest(for: urlWithoutRedirect) 67 | discoveryWithoutRedirect.start { 68 | print("Check profile url after discovery request: \(discoveryWithoutRedirect.profile)") 69 | XCTAssertEqual(discoveryWithoutRedirect.profile, URL(string: "https://eddiehinkle.com/")!) 70 | expectationWithoutRedirect.fulfill() 71 | } 72 | 73 | waitForExpectations(timeout: 5, handler: nil) 74 | 75 | } 76 | 77 | func testProfileDiscoveryEndpointsHTTPLink() { 78 | 79 | let profile = URL(string: "https://aaronpk.com")! 80 | let profileKnownEndpoints = [EndpointType.authorization_endpoint: URL(string: "https://aaronparecki.com/auth")!, 81 | EndpointType.token_endpoint: URL(string: "https://aaronparecki.com/auth/token")!, 82 | EndpointType.micropub: URL(string: "https://aaronparecki.com/micropub")!, 83 | EndpointType.microsub: URL(string: "https://aperture.p3k.io/microsub/1")!] 84 | let expectation = self.expectation(description: "ProfileDiscovyerEndpoints") 85 | let discovery = ProfileDiscoveryRequest(for: profile) 86 | discovery.start { 87 | XCTAssertEqual(discovery.endpoints[EndpointType.authorization_endpoint], profileKnownEndpoints[EndpointType.authorization_endpoint]) 88 | XCTAssertEqual(discovery.endpoints[EndpointType.token_endpoint], profileKnownEndpoints[EndpointType.token_endpoint]) 89 | XCTAssertEqual(discovery.endpoints[EndpointType.micropub], profileKnownEndpoints[EndpointType.micropub]) 90 | XCTAssertEqual(discovery.endpoints[EndpointType.microsub], profileKnownEndpoints[EndpointType.microsub]) 91 | expectation.fulfill() 92 | } 93 | 94 | waitForExpectations(timeout: 5, handler: nil) 95 | } 96 | 97 | func testProfileDiscoveryEndpointsHTML() { 98 | 99 | let profile = URL(string: "https://eddiehinkle.com/")! 100 | let profileKnownEndpoints = [EndpointType.authorization_endpoint: URL(string: "https://eddiehinkle.com/auth")!, 101 | EndpointType.token_endpoint: URL(string: "https://eddiehinkle.com/auth/token")!, 102 | EndpointType.micropub: URL(string: "https://eddiehinkle.com/micropub")!, 103 | EndpointType.microsub: URL(string: "https://aperture.eddiehinkle.com/microsub/1")!, 104 | EndpointType.webmention: URL(string: "https://webmention.io/eddiehinkle.com/webmention")!] 105 | let expectation = self.expectation(description: "ProfileDiscovyerEndpoints") 106 | let discovery = ProfileDiscoveryRequest(for: profile) 107 | discovery.start { 108 | XCTAssertEqual(discovery.endpoints, profileKnownEndpoints) 109 | expectation.fulfill() 110 | } 111 | 112 | waitForExpectations(timeout: 5, handler: nil) 113 | } 114 | 115 | // This test is a good one, because all the endpoints are in the HTTP Link headers EXCEPT the webmention endpoint, that is located in the HTML 116 | // So if this test fails there is either something wrong with the Link header code OR the HTML parsing code 117 | func testProfileDiscoveryEndpointsBoth() { 118 | 119 | let profile = URL(string: "https://aaronpk.com")! 120 | let profileKnownEndpoints = [EndpointType.authorization_endpoint: URL(string: "https://aaronparecki.com/auth")!, 121 | EndpointType.token_endpoint: URL(string: "https://aaronparecki.com/auth/token")!, 122 | EndpointType.micropub: URL(string: "https://aaronparecki.com/micropub")!, 123 | EndpointType.microsub: URL(string: "https://aperture.p3k.io/microsub/1")!, 124 | EndpointType.webmention: URL(string: "https://webmention.io/aaronpk/webmention")!] 125 | let expectation = self.expectation(description: "ProfileDiscovyerEndpoints") 126 | let discovery = ProfileDiscoveryRequest(for: profile) 127 | discovery.start { 128 | XCTAssertEqual(discovery.endpoints, profileKnownEndpoints) 129 | expectation.fulfill() 130 | } 131 | 132 | waitForExpectations(timeout: 5, handler: nil) 133 | } 134 | 135 | func testProfileDiscoveryEndpointsRelative() { 136 | 137 | let profile = URL(string: "https://vanderven.se/martijn/")! 138 | let profileKnownEndpoints = [EndpointType.authorization_endpoint: URL(string: "https://vanderven.se/martijn/auth/")!, 139 | EndpointType.webmention: URL(string: "https://vanderven.se/martijn/mention.php")!] 140 | 141 | // let discovery = ProfileDiscoveryRequest(for: profile) 142 | // discovery.parseSiteData(response: HTTPURLResponse(), htmlData: nil) 143 | 144 | let expectation = self.expectation(description: "ProfileDiscovyerEndpoints") 145 | let discovery = ProfileDiscoveryRequest(for: profile) 146 | discovery.start { 147 | XCTAssertEqual(discovery.endpoints, profileKnownEndpoints) 148 | expectation.fulfill() 149 | } 150 | 151 | waitForExpectations(timeout: 5, handler: nil) 152 | 153 | } 154 | 155 | // IndieAuth Spec 5.2 Building Authentication Request URL 156 | // https://indieauth.spec.indieweb.org/#authentication-request 157 | func testAuthenticationRequestUrl() { 158 | 159 | let profile = URL(string: "https://eddiehinkle.com")! 160 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 161 | let client_id = URL(string: "https://remark.social")! 162 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 163 | let state = String.randomAlphaNumericString(length: 25) 164 | 165 | let request = IndieAuthRequest(.Authentication, 166 | for: profile, 167 | at: authorization_endpoint, 168 | clientId: client_id, 169 | redirectUri: redirect_uri, 170 | state: state) 171 | 172 | XCTAssertTrue(request.url!.absoluteString.hasPrefix("\(authorization_endpoint)?me=\(profile)&client_id=\(client_id)&redirect_uri=\(redirect_uri)&state=\(state)&response_type=id&code_challenge_method=S256&code_challenge=")) 173 | } 174 | 175 | // IndieAuth Spec 5.3 Parsing the Authentication Response 176 | // https://indieauth.spec.indieweb.org/#authentication-response 177 | func testParseAuthenticationResponse() { 178 | let profile = URL(string: "https://eddiehinkle.com")! 179 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 180 | let client_id = URL(string: "https://remark.social")! 181 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 182 | let state = String.randomAlphaNumericString(length: 25) 183 | 184 | let request = IndieAuthRequest(.Authentication, 185 | for: profile, 186 | at: authorization_endpoint, 187 | clientId: client_id, 188 | redirectUri: redirect_uri, 189 | state: state) 190 | 191 | let authorization_code_from_server = String.randomAlphaNumericString(length: 20) 192 | 193 | let parsed_authorization_code = request.parseResponse(URL(string: "\(redirect_uri)?code=\(authorization_code_from_server)&state=\(state)")!) 194 | XCTAssertEqual(parsed_authorization_code, authorization_code_from_server) 195 | } 196 | 197 | // IndieAuth Spec 5.4 Authorization Code Verification Request 198 | // https://indieauth.spec.indieweb.org/#authorization-code-verification 199 | func testAuthorizationCodeVerificationRequest() { 200 | 201 | let profile = URL(string: "https://eddiehinkle.com")! 202 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 203 | let client_id = URL(string: "https://remark.social")! 204 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 205 | let state = String.randomAlphaNumericString(length: 25) 206 | 207 | let request = IndieAuthRequest(.Authentication, 208 | for: profile, 209 | at: authorization_endpoint, 210 | clientId: client_id, 211 | redirectUri: redirect_uri, 212 | state: state) 213 | 214 | let authorization_code = String.randomAlphaNumericString(length: 20) 215 | 216 | let verificationRequest: URLRequest = try! request.getVerificationRequest(with: authorization_code) 217 | 218 | XCTAssertEqual(verificationRequest.httpMethod, "POST") 219 | XCTAssertEqual(verificationRequest.url, authorization_endpoint) 220 | 221 | let bodyDictionary = try! JSONDecoder().decode([String:String].self, from: verificationRequest.httpBody!) 222 | 223 | XCTAssertEqual(bodyDictionary["code"], authorization_code) 224 | XCTAssertEqual(bodyDictionary["client_id"], client_id.absoluteString) 225 | XCTAssertEqual(bodyDictionary["redirect_uri"], redirect_uri.absoluteString) 226 | XCTAssertTrue(request.checkCodeChallenge(bodyDictionary["code_verifier"]!)) 227 | } 228 | 229 | // IndieAuth Spec 5.4 Authorization Code Verification Response 230 | // https://indieauth.spec.indieweb.org/#authorization-code-verification 231 | func testAuthorizationCodeVerificationResponse() { 232 | 233 | let profile = URL(string: "https://eddiehinkle.com")! 234 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 235 | let client_id = URL(string: "https://remark.social")! 236 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 237 | let state = String.randomAlphaNumericString(length: 25) 238 | 239 | let request = IndieAuthRequest(.Authentication, 240 | for: profile, 241 | at: authorization_endpoint, 242 | clientId: client_id, 243 | redirectUri: redirect_uri, 244 | state: state) 245 | 246 | let sameProfile = profile 247 | let responseWithSameProfile = [ "me": sameProfile ] 248 | let isValidMe = request.confirmVerificationResponse(responseWithSameProfile) 249 | XCTAssertTrue(isValidMe) 250 | 251 | var subProfile = URLComponents(url: profile, resolvingAgainstBaseURL: false)! 252 | subProfile.path = "/path/under" 253 | let responseWithSubProfile = [ "me": subProfile.url! ] 254 | let isValidMe2 = request.confirmVerificationResponse(responseWithSubProfile) 255 | XCTAssertTrue(isValidMe2) 256 | 257 | let spoofedProfile = URL(string: "https://spoofing.com")! 258 | let responseWithSpoofedProfile = [ "me": spoofedProfile ] 259 | let isValidMe3 = request.confirmVerificationResponse(responseWithSpoofedProfile) 260 | XCTAssertFalse(isValidMe3) 261 | } 262 | 263 | // IndieAuth Spec 6.2.1 Building Authorization Request URL 264 | // https://indieauth.spec.indieweb.org/#authorization-request 265 | func testAuthorizationRequestUrl() { 266 | 267 | let profile = URL(string: "https://eddiehinkle.com")! 268 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 269 | let client_id = URL(string: "https://remark.social")! 270 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 271 | let state = String.randomAlphaNumericString(length: 25) 272 | let scope = ["create", "update", "delete"] 273 | 274 | let requestWithoutScope = IndieAuthRequest(.Authorization, 275 | for: profile, 276 | at: authorization_endpoint, 277 | clientId: client_id, 278 | redirectUri: redirect_uri, 279 | state: state) 280 | 281 | XCTAssertTrue(requestWithoutScope.url!.absoluteString.hasPrefix("\(authorization_endpoint)?me=\(profile)&client_id=\(client_id)&redirect_uri=\(redirect_uri)&state=\(state)&response_type=code&code_challenge_method=S256&code_challenge=")) 282 | 283 | let requestWithScope = IndieAuthRequest(.Authorization, 284 | for: profile, 285 | at: authorization_endpoint, 286 | clientId: client_id, 287 | redirectUri: redirect_uri, 288 | state: state, 289 | scope: scope) 290 | 291 | XCTAssertTrue(requestWithScope.url!.absoluteString.hasPrefix("\(authorization_endpoint)?me=\(profile)&client_id=\(client_id)&redirect_uri=\(redirect_uri)&state=\(state)&scope=create%20update%20delete&response_type=code&code_challenge_method=S256&code_challenge=")) 292 | } 293 | 294 | // IndieAuth Spec 6.2.2 Parsing the Authorization Response 295 | // https://indieauth.spec.indieweb.org/#authorization-response 296 | func testParseAuthorizationResponse() { 297 | let profile = URL(string: "https://eddiehinkle.com")! 298 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 299 | let client_id = URL(string: "https://remark.social")! 300 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 301 | let state = String.randomAlphaNumericString(length: 25) 302 | 303 | let request = IndieAuthRequest(.Authorization, 304 | for: profile, 305 | at: authorization_endpoint, 306 | clientId: client_id, 307 | redirectUri: redirect_uri, 308 | state: state) 309 | 310 | let authorization_code_from_server = String.randomAlphaNumericString(length: 20) 311 | 312 | let parsed_authorization_code = request.parseResponse(URL(string: "\(redirect_uri)?code=\(authorization_code_from_server)&state=\(state)")!) 313 | XCTAssertEqual(parsed_authorization_code, authorization_code_from_server) 314 | } 315 | 316 | // IndieAuth Spec 6.3.1 Generate Token Request 317 | // https://indieauth.spec.indieweb.org/#token-request 318 | func testTokenRequest() { 319 | 320 | let profile = URL(string: "https://eddiehinkle.com")! 321 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 322 | let token_endpoint = URL(string: "https://eddiehinkle.com/auth/token")! 323 | let client_id = URL(string: "https://remark.social")! 324 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 325 | let state = String.randomAlphaNumericString(length: 25) 326 | 327 | let request = IndieAuthRequest(.Authorization, 328 | for: profile, 329 | at: authorization_endpoint, 330 | with: token_endpoint, 331 | clientId: client_id, 332 | redirectUri: redirect_uri, 333 | state: state) 334 | 335 | let authorization_code = String.randomAlphaNumericString(length: 20) 336 | 337 | let tokenRequest: URLRequest = try! request.getTokenRequest(with: authorization_code) 338 | 339 | XCTAssertEqual(tokenRequest.httpMethod, "POST") 340 | XCTAssertEqual(tokenRequest.url, token_endpoint) 341 | 342 | let bodyDictionary = try! JSONDecoder().decode([String:String].self, from: tokenRequest.httpBody!) 343 | 344 | XCTAssertEqual(bodyDictionary["grant_type"], "authorization_code") 345 | XCTAssertEqual(bodyDictionary["code"], authorization_code) 346 | XCTAssertEqual(bodyDictionary["client_id"], client_id.absoluteString) 347 | XCTAssertEqual(bodyDictionary["redirect_uri"], redirect_uri.absoluteString) 348 | XCTAssertEqual(bodyDictionary["me"], profile.absoluteString) 349 | XCTAssertTrue(request.checkCodeChallenge(bodyDictionary["code_verifier"]!)) 350 | } 351 | 352 | // IndieAuth Spec 6.3.3 Access Token Response 353 | // https://indieauth.spec.indieweb.org/#access-token-response 354 | func testTokenResponse() { 355 | 356 | let profile = URL(string: "https://eddiehinkle.com")! 357 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 358 | let token_endpoint = URL(string: "https://eddiehinkle.com/auth/token")! 359 | let client_id = URL(string: "https://remark.social")! 360 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 361 | let state = String.randomAlphaNumericString(length: 25) 362 | 363 | let request = IndieAuthRequest(.Authorization, 364 | for: profile, 365 | at: authorization_endpoint, 366 | with: token_endpoint, 367 | clientId: client_id, 368 | redirectUri: redirect_uri, 369 | state: state) 370 | 371 | let access_token_from_server = String.randomAlphaNumericString(length: 25) 372 | let token_type = "Bearer" 373 | let scope_from_server = "create update delete" 374 | let me_profile = profile 375 | 376 | let responseFromServer: [String:String] = [ 377 | "access_token": access_token_from_server, 378 | "token_type": token_type, 379 | "scope": scope_from_server, 380 | "me": me_profile.absoluteString 381 | ] 382 | 383 | let returnData = try! JSONEncoder().encode(responseFromServer) 384 | let (tokenType, accessToken) = try! request.parseTokenResponse(returnData) 385 | 386 | XCTAssertEqual(request.scope.joined(separator: " "), responseFromServer["scope"]) 387 | XCTAssertEqual(request.profile.absoluteString, responseFromServer["me"]) 388 | XCTAssertEqual(tokenType, token_type) 389 | XCTAssertEqual(accessToken, access_token_from_server) 390 | } 391 | 392 | // IndieAuth Spec 6.3.3 Access Token Response 393 | // https://indieauth.spec.indieweb.org/#access-token-response 394 | func testTokenResponseWithSubProfile() { 395 | 396 | let profile = URL(string: "https://eddiehinkle.com")! 397 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 398 | let token_endpoint = URL(string: "https://eddiehinkle.com/auth/token")! 399 | let client_id = URL(string: "https://remark.social")! 400 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 401 | let state = String.randomAlphaNumericString(length: 25) 402 | 403 | let request = IndieAuthRequest(.Authorization, 404 | for: profile, 405 | at: authorization_endpoint, 406 | with: token_endpoint, 407 | clientId: client_id, 408 | redirectUri: redirect_uri, 409 | state: state) 410 | 411 | let access_token_from_server = String.randomAlphaNumericString(length: 25) 412 | let token_type = "Bearer" 413 | let scope_from_server = "create update delete" 414 | let me_profile = URL(string: "https://eddiehinkle.com/sub/profile")! 415 | 416 | let responseFromServer: [String:String] = [ 417 | "access_token": access_token_from_server, 418 | "token_type": token_type, 419 | "scope": scope_from_server, 420 | "me": me_profile.absoluteString 421 | ] 422 | 423 | let returnData = try! JSONEncoder().encode(responseFromServer) 424 | let (tokenType, accessToken) = try! request.parseTokenResponse(returnData) 425 | 426 | XCTAssertEqual(request.scope.joined(separator: " "), responseFromServer["scope"]) 427 | XCTAssertEqual(request.profile.absoluteString, responseFromServer["me"]) 428 | XCTAssertEqual(tokenType, token_type) 429 | XCTAssertEqual(accessToken, access_token_from_server) 430 | } 431 | 432 | // IndieAuth Spec 6.3.3 Access Token Response 433 | // https://indieauth.spec.indieweb.org/#access-token-response 434 | func testTokenResponseWithInvalidProfile() { 435 | 436 | let profile = URL(string: "https://eddiehinkle.com")! 437 | let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 438 | let token_endpoint = URL(string: "https://eddiehinkle.com/auth/token")! 439 | let client_id = URL(string: "https://remark.social")! 440 | let redirect_uri = URL(string: "https://remark.social/ios/callback")! 441 | let state = String.randomAlphaNumericString(length: 25) 442 | 443 | let request = IndieAuthRequest(.Authorization, 444 | for: profile, 445 | at: authorization_endpoint, 446 | with: token_endpoint, 447 | clientId: client_id, 448 | redirectUri: redirect_uri, 449 | state: state) 450 | 451 | let access_token_from_server = String.randomAlphaNumericString(length: 25) 452 | let token_type = "Bearer" 453 | let scope_from_server = "create update delete" 454 | let me_profile = URL(string: "https://spoofing.com/profile")! 455 | 456 | let responseFromServer: [String:String] = [ 457 | "access_token": access_token_from_server, 458 | "token_type": token_type, 459 | "scope": scope_from_server, 460 | "me": me_profile.absoluteString 461 | ] 462 | 463 | let returnData = try! JSONEncoder().encode(responseFromServer) 464 | var tokenType: String?, accessToken: String? 465 | 466 | do { 467 | (tokenType, accessToken) = try request.parseTokenResponse(returnData) 468 | } catch(IndieAuthError.authorizationError(let errorString)) { 469 | XCTAssertNotNil(errorString) 470 | } catch { 471 | // The error that should be caught should be above. 472 | // If we reach here, the logic has broken 473 | XCTAssertTrue(false) 474 | } 475 | 476 | XCTAssertEqual(request.scope.joined(separator: " "), "") 477 | XCTAssertEqual(request.profile.absoluteString, profile.absoluteString) 478 | XCTAssertNil(tokenType) 479 | XCTAssertNil(accessToken) 480 | } 481 | 482 | // IndieAuth Spec 6.3.5 Request Token Revocation 483 | // https://indieauth.spec.indieweb.org/#token-revocation 484 | func testTokenRevocationRequest() { 485 | // let profile = URL(string: "https://eddiehinkle.com")! 486 | // let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")! 487 | // let token_endpoint = URL(string: "https://eddiehinkle.com/auth/token")! 488 | // let client_id = URL(string: "https://remark.social")! 489 | // let redirect_uri = URL(string: "https://remark.social/ios/callback")! 490 | // let state = String.randomAlphaNumericString(length: 25) 491 | // 492 | // let request = IndieAuthRequest(.Authorization, 493 | // for: profile, 494 | // at: authorization_endpoint, 495 | // with: token_endpoint, 496 | // clientId: client_id, 497 | // redirectUri: redirect_uri, 498 | // state: state) 499 | // 500 | // let access_token_from_server = String.randomAlphaNumericString(length: 25) 501 | // 502 | // request. 503 | } 504 | 505 | // TODO: Write a test that returns several of the same endpoint and make sure that the FIRST endpoint is used 506 | 507 | static var allTests = [ 508 | ("Test Valid Profile Urls", testValidProfileUrls), 509 | ("Test Invalid Profile Urls", testInvalidProfileUrls), 510 | //("Test Normalized Profile Urls", testNormalizeHostnameUrl), 511 | ("Test Profile Discovery", testProfileDiscoveryRedirection), 512 | ("Test Profile Discovery Endpoints for HTTP Link", testProfileDiscoveryEndpointsHTTPLink), 513 | ("Test Profile Discovery Endpoints for HTML", testProfileDiscoveryEndpointsHTML), 514 | ("Test Profile Discovery Endpoint Both", testProfileDiscoveryEndpointsBoth) 515 | ] 516 | } 517 | -------------------------------------------------------------------------------- /Tests/IndieWebKitTests/MicropubTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import IndieWebKit 3 | 4 | let micropubRocksClient = "https://micropub.rocks/client/HTSxBUnl2jHeMh1Y" 5 | let micropubEndpoint = URL(string: "\(micropubRocksClient)/micropub")! 6 | let accessToken = "BGp5NuExxhtVYiukM0NlC4mr3mczuMt8vxvNlUMkmaUMqKXdh6pUpmOZGd5dniVr257CyS4WKP4jgssd7JPx4CHln260pw0jQpL11bworiQ0E19b7xNnWMtCJX265XTq" 7 | 8 | final class MicropubTests: XCTestCase { 9 | 10 | // Micropub.rocks 100 - Create an h-entry post (form-encoded) 11 | func testCreateFormEncodedHEntryPost() { 12 | // XCTAssertTrue(false) 13 | } 14 | 15 | // Micropub spec 3.7.1 Configuration Query 16 | // https://micropub.net/draft/#configuration 17 | // Micropub.rocks test 600 18 | func testMicropubConfigMicropubRocks() { 19 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 20 | 21 | let waiting = expectation(description: "Retrieve Micropub Config") 22 | try! micropub.getConfigQuery { config in 23 | XCTAssertNotNil(config) 24 | XCTAssertNotNil(config!.mediaEndpoint) 25 | XCTAssertEqual(config!.mediaEndpoint, URL(string: "\(micropubRocksClient)/media")!) 26 | XCTAssertNotNil(config!.syndicateTo) 27 | XCTAssertEqual(config!.syndicateTo?.count, 1) 28 | XCTAssertEqual(config!.syndicateTo?[0].uid, "https://news.indieweb.org/en") 29 | XCTAssertEqual(config!.syndicateTo?[0].name, "IndieNews") 30 | waiting.fulfill() 31 | } 32 | 33 | waitForExpectations(timeout: 5, handler: nil) 34 | } 35 | 36 | // Micropub spec 3.7.3 Syndication Targets Query 37 | // https://micropub.net/draft/#configuration 38 | // Micropub.rocks test 601 39 | func testMicropubSyndicationTargetQueryMicropubRocks() { 40 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 41 | 42 | let waiting = expectation(description: "Retrieve Micropub Syndication Targets") 43 | try! micropub.getSyndicationTargetQuery { syndicationTargets in 44 | XCTAssertNotNil(syndicationTargets) 45 | XCTAssertEqual(syndicationTargets?.count, 1) 46 | XCTAssertEqual(syndicationTargets?[0].uid, "https://news.indieweb.org/en") 47 | XCTAssertEqual(syndicationTargets?[0].name, "IndieNews") 48 | waiting.fulfill() 49 | } 50 | 51 | waitForExpectations(timeout: 5, handler: nil) 52 | } 53 | 54 | // Micropub spec 3.7.1 Configuration Query 55 | // https://micropub.net/draft/#configuration 56 | // Test against Micro.blog 57 | // func testMicropubConfigMicroDotBlog() { 58 | // let micropub = MicropubSession(to: URL(string: "https://micro.blog/micropub")!, with: "") 59 | // 60 | // let waiting = expectation(description: "Retrieve Micropub Config") 61 | // try! micropub.getConfigQuery { config in 62 | // XCTAssertNotNil(config) 63 | // XCTAssertNotNil(config!.mediaEndpoint) 64 | // XCTAssertEqual(config!.mediaEndpoint, URL(string: "https://micro.blog/micropub/media")!) 65 | // XCTAssertNotNil(config!.postTypes) 66 | // XCTAssertEqual(config!.postTypes?.count, 5) 67 | // XCTAssertEqual(config!.postTypes?[0].type, .note) 68 | // XCTAssertEqual(config!.postTypes?[0].name, "Post") 69 | // XCTAssertEqual(config!.postTypes?[1].type, .article) 70 | // XCTAssertEqual(config!.postTypes?[1].name, "Article") 71 | // XCTAssertEqual(config!.postTypes?[2].type, .photo) 72 | // XCTAssertEqual(config!.postTypes?[2].name, "Photo") 73 | // XCTAssertEqual(config!.postTypes?[3].type, .reply) 74 | // XCTAssertEqual(config!.postTypes?[3].name, "Reply") 75 | // XCTAssertEqual(config!.postTypes?[4].type, .bookmark) 76 | // XCTAssertEqual(config!.postTypes?[4].name, "Favorite") 77 | // XCTAssertNotNil(config!.destination) 78 | // XCTAssertEqual(config!.destination?.count, 1) 79 | // XCTAssertEqual(config!.destination?[0].uid, "https://30andcounting.micro.blog/") 80 | // XCTAssertEqual(config!.destination?[0].name, "30andcounting.micro.blog") 81 | // waiting.fulfill() 82 | // } 83 | // 84 | // waitForExpectations(timeout: 5, handler: nil) 85 | // } 86 | 87 | // Micropub spec 3.5 Delete Posts 88 | // https://micropub.net/draft/#delete 89 | // Micropub.rocks test 500 90 | func testMicropubDelete() { 91 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 92 | let post = MicropubPost(type: .entry, url: URL(string: "\(micropubRocksClient)/500/ZZLy8uMe")!) 93 | 94 | let waiting = expectation(description: "Send Micropub Request") 95 | try! micropub.sendMicropubPost(post, as: .FormEncoded, with: .delete) { postUrl in 96 | XCTAssertNotNil(postUrl) 97 | XCTAssertEqual(postUrl, post.url) 98 | waiting.fulfill() 99 | } 100 | 101 | waitForExpectations(timeout: 5, handler: nil) 102 | } 103 | 104 | // Micropub spec 3.5 Undelete Posts 105 | // https://micropub.net/draft/#delete 106 | // Micropub.rocks test 502 107 | func testMicropubUndelete() { 108 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 109 | let post = MicropubPost(type: .entry, url: URL(string: "\(micropubRocksClient)/502/mk1U37Oz")!) 110 | 111 | let waiting = expectation(description: "Send Micropub Request") 112 | try! micropub.sendMicropubPost(post, as: .FormEncoded, with: .undelete) { postUrl in 113 | XCTAssertNotNil(postUrl) 114 | XCTAssertEqual(postUrl, post.url) 115 | waiting.fulfill() 116 | } 117 | 118 | waitForExpectations(timeout: 5, handler: nil) 119 | } 120 | 121 | // Micropub.rocks test 100 122 | func testCreateHEntryPostAsFormEncoded() { 123 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 124 | let post = MicropubPost(type: .entry, content: "Hello World!") 125 | 126 | let waiting = expectation(description: "Send Micropub Request") 127 | try! micropub.sendMicropubPost(post, as: .FormEncoded) { postUrl in 128 | XCTAssertNotNil(postUrl) 129 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/100")) 130 | waiting.fulfill() 131 | } 132 | 133 | waitForExpectations(timeout: 5, handler: nil) 134 | } 135 | 136 | // Micropub.rocks test 101 137 | func testCreateHEntryPostWithCategoriesAsFormEncoded() { 138 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 139 | let post = MicropubPost(type: .entry, content: "Hello World!", categories: ["indieweb", "swift", "indiewebkit"]) 140 | 141 | let waiting = expectation(description: "Send Micropub Request") 142 | try! micropub.sendMicropubPost(post, as: .FormEncoded) { postUrl in 143 | XCTAssertNotNil(postUrl) 144 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/101")) 145 | waiting.fulfill() 146 | } 147 | 148 | waitForExpectations(timeout: 5, handler: nil) 149 | } 150 | 151 | // Micropub.rocks test 104 152 | func testCreateHEntryPostWithFileUrlAsFormEncoded() { 153 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 154 | let post = MicropubPost(type: .entry, 155 | content: "Hello World!", 156 | externalPhoto: [ExternalFile(value: URL(string: "https://eddiehinkle.com/images/profile.jpg")!)]) 157 | 158 | let waiting = expectation(description: "Send Micropub Request") 159 | try! micropub.sendMicropubPost(post, as: .FormEncoded) { postUrl in 160 | XCTAssertNotNil(postUrl) 161 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/104")) 162 | waiting.fulfill() 163 | } 164 | 165 | waitForExpectations(timeout: 5, handler: nil) 166 | } 167 | 168 | // Micropub.rocks test 105 169 | func testCreateHEntryPostWithSyndicationAsFormEncoded() { 170 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 171 | let post = MicropubPost(type: .entry, 172 | content: "Hello World!", 173 | syndicateTo: [SyndicationTarget(uid: "https://news.indieweb.org/en", name: "IndieNews", service: nil, user: nil)]) 174 | 175 | let waiting = expectation(description: "Send Micropub Request") 176 | try! micropub.sendMicropubPost(post, as: .FormEncoded) { postUrl in 177 | XCTAssertNotNil(postUrl) 178 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/105")) 179 | waiting.fulfill() 180 | } 181 | 182 | waitForExpectations(timeout: 5, handler: nil) 183 | } 184 | 185 | // Micropub.rocks test 200 186 | func testCreateHEntryPostAsJSON() { 187 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 188 | let post = MicropubPost(type: .entry, content: "Hello World!") 189 | 190 | let waiting = expectation(description: "Send Micropub Request") 191 | try! micropub.sendMicropubPost(post, as: .JSON) { postUrl in 192 | XCTAssertNotNil(postUrl) 193 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/200")) 194 | waiting.fulfill() 195 | } 196 | 197 | waitForExpectations(timeout: 5, handler: nil) 198 | } 199 | 200 | // Micropub.rocks test 201 201 | func testCreateHEntryPostWithCategoriesAsJSON() { 202 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 203 | let post = MicropubPost(type: .entry, content: "Hello World!", categories: ["indieweb", "swift", "indiewebkit"]) 204 | 205 | let waiting = expectation(description: "Send Micropub Request") 206 | try! micropub.sendMicropubPost(post, as: .JSON) { postUrl in 207 | XCTAssertNotNil(postUrl) 208 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/201")) 209 | waiting.fulfill() 210 | } 211 | 212 | waitForExpectations(timeout: 5, handler: nil) 213 | } 214 | 215 | // Micropub.rocks test 203 216 | func testCreateHEntryPostWithFileUrlAsJSON() { 217 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 218 | let post = MicropubPost(type: .entry, 219 | content: "Hello World!", 220 | externalPhoto: [ExternalFile(value: URL(string: "https://eddiehinkle.com/images/profile.jpg")!)]) 221 | 222 | let waiting = expectation(description: "Send Micropub Request") 223 | try! micropub.sendMicropubPost(post, as: .JSON) { postUrl in 224 | XCTAssertNotNil(postUrl) 225 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/203")) 226 | waiting.fulfill() 227 | } 228 | 229 | waitForExpectations(timeout: 5, handler: nil) 230 | } 231 | 232 | // Micropub.rocks test 202 233 | func testCreateHEntryPostWithHTMLAsJSON() { 234 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 235 | let post = MicropubPost(type: .entry, 236 | htmlContent: "Testing") 237 | 238 | let waiting = expectation(description: "Send Micropub Request") 239 | try! micropub.sendMicropubPost(post, as: .JSON) { postUrl in 240 | XCTAssertNotNil(postUrl) 241 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/202")) 242 | waiting.fulfill() 243 | } 244 | 245 | waitForExpectations(timeout: 5, handler: nil) 246 | } 247 | 248 | // Micropub.rocks test 205 249 | func testCreateHEntryPostWithPhotoAltTextAsJSON() { 250 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 251 | let post = MicropubPost(type: .entry, 252 | externalPhoto: [ExternalFile(value: URL(string: "https://eddiehinkle.com/images/profile.jpg")!, alt: "Alt Text")]) 253 | 254 | let waiting = expectation(description: "Send Micropub Request") 255 | try! micropub.sendMicropubPost(post, as: .JSON) { postUrl in 256 | XCTAssertNotNil(postUrl) 257 | XCTAssertTrue(postUrl!.absoluteString.hasPrefix("\(micropubRocksClient)/205")) 258 | waiting.fulfill() 259 | } 260 | 261 | waitForExpectations(timeout: 5, handler: nil) 262 | } 263 | 264 | // Micropub.rocks test 602 265 | func testSourceQueryAllProperties() { 266 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 267 | let post = MicropubPost(type: .entry, 268 | url: URL(string: "https://micropub.rocks/client/HTSxBUnl2jHeMh1Y/602/SWLEBPmG")!) 269 | 270 | let waiting = expectation(description: "Send Source Query") 271 | try! micropub.getSourceQuery(for: post) { returnedPost in 272 | XCTAssertNotNil(returnedPost) 273 | XCTAssertEqual(returnedPost?.count, 1) 274 | XCTAssertEqual(returnedPost?[0].content, "Hello world") 275 | XCTAssertNotNil(returnedPost?[0].categories) 276 | XCTAssertEqual(returnedPost?[0].categories?.count, 2) 277 | waiting.fulfill() 278 | } 279 | 280 | waitForExpectations(timeout: 5, handler: nil) 281 | } 282 | 283 | // Micropub.rocks test 603 284 | func testSourceQuerySomeProperties() { 285 | let micropub = MicropubSession(to: micropubEndpoint, with: accessToken) 286 | let post = MicropubPost(type: .entry, 287 | url: URL(string: "https://micropub.rocks/client/HTSxBUnl2jHeMh1Y/603/ZdIc2DYh")!) 288 | 289 | let waiting = expectation(description: "Send Source Query") 290 | try! micropub.getSourceQuery(for: post, with: [.content]) { returnedPost in 291 | XCTAssertNotNil(returnedPost) 292 | XCTAssertEqual(returnedPost?.count, 1) 293 | XCTAssertEqual(returnedPost?[0].content, "Hello world") 294 | XCTAssertNil(returnedPost?[0].categories) 295 | waiting.fulfill() 296 | } 297 | 298 | waitForExpectations(timeout: 5, handler: nil) 299 | } 300 | 301 | static var allTests = [ 302 | ("Create form encoded h-entry post", testCreateFormEncodedHEntryPost), 303 | ("Test Micropub Config", testMicropubConfigMicropubRocks) 304 | ] 305 | } 306 | -------------------------------------------------------------------------------- /Tests/IndieWebKitTests/MicrosubTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import IndieWebKit 3 | 4 | let microsubEndpoint = URL(string: "https://example.com/microsub")! 5 | let microsubAccessToken = "odiajiosdjoasijdioasjdoij" 6 | 7 | final class MicrosubTests: XCTestCase { 8 | 9 | func testMicrosubRequestHeadersRequest() { 10 | let action = MicrosubTimelineAction(markAsReadIn: "channelTestName", before: "LastEntryId") 11 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 12 | 13 | let headers = request.allHTTPHeaderFields 14 | XCTAssertNotNil(headers) 15 | XCTAssertEqual(headers!["Content-Type"], MicropubSendType.FormEncoded.rawValue) 16 | XCTAssertEqual(headers!["Authorization"], "Bearer \(microsubAccessToken)") 17 | } 18 | 19 | func testMarkLastEntriesAsReadRequest() { 20 | let action = MicrosubTimelineAction(markAsReadIn: "channelTestName", before: "LastEntryId") 21 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 22 | let body = String(data: request.httpBody!, encoding: .utf8) 23 | 24 | XCTAssertEqual(request.httpMethod, "POST") 25 | XCTAssertNotNil(body) 26 | XCTAssertTrue(body!.contains("action=timeline")) 27 | XCTAssertTrue(body!.contains("method=mark_read")) 28 | XCTAssertTrue(body!.contains("channel=channelTestName")) 29 | XCTAssertTrue(body!.contains("last_read_entry=LastEntryId")) 30 | } 31 | 32 | func testMarkMultipleEntriesAsReadRequest() { 33 | let action = MicrosubTimelineAction(with: .markRead, for: "channelTestName", on: ["entry1", "entry2"]) 34 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 35 | let body = String(data: request.httpBody!, encoding: .utf8) 36 | 37 | XCTAssertEqual(request.httpMethod, "POST") 38 | XCTAssertNotNil(body) 39 | XCTAssertTrue(body!.contains("action=timeline")) 40 | XCTAssertTrue(body!.contains("method=mark_read")) 41 | XCTAssertTrue(body!.contains("channel=channelTestName")) 42 | XCTAssertTrue(body!.contains("entry[]=entry1&entry[]=entry2")) 43 | } 44 | 45 | func testMarkMultipleEntriesAsUnreadRequest() { 46 | let action = MicrosubTimelineAction(with: .markUnread, for: "channelTestName", on: ["entry1", "entry2"]) 47 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 48 | let body = String(data: request.httpBody!, encoding: .utf8) 49 | 50 | XCTAssertEqual(request.httpMethod, "POST") 51 | XCTAssertNotNil(body) 52 | XCTAssertTrue(body!.contains("action=timeline")) 53 | XCTAssertTrue(body!.contains("method=mark_unread")) 54 | XCTAssertTrue(body!.contains("channel=channelTestName")) 55 | XCTAssertTrue(body!.contains("entry[]=entry1&entry[]=entry2")) 56 | } 57 | 58 | func testMarkSingleEntryAsReadRequest() { 59 | let action = MicrosubTimelineAction(with: .markRead, for: "channelTestName", on: ["entryId"]) 60 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 61 | let body = String(data: request.httpBody!, encoding: .utf8) 62 | 63 | XCTAssertEqual(request.httpMethod, "POST") 64 | XCTAssertNotNil(body) 65 | XCTAssertTrue(body!.contains("action=timeline")) 66 | XCTAssertTrue(body!.contains("method=mark_read")) 67 | XCTAssertTrue(body!.contains("channel=channelTestName")) 68 | XCTAssertTrue(body!.contains("entry=entryId")) 69 | } 70 | 71 | func testMarkSingleEntryAsUnreadRequest() { 72 | let action = MicrosubTimelineAction(with: .markUnread, for: "channelTestName", on: ["entryId"]) 73 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 74 | let body = String(data: request.httpBody!, encoding: .utf8) 75 | 76 | XCTAssertEqual(request.httpMethod, "POST") 77 | XCTAssertNotNil(body) 78 | XCTAssertTrue(body!.contains("action=timeline")) 79 | XCTAssertTrue(body!.contains("method=mark_unread")) 80 | XCTAssertTrue(body!.contains("channel=channelTestName")) 81 | XCTAssertTrue(body!.contains("entry=entryId")) 82 | } 83 | 84 | func testRemoveEntryFromChannelRequest() { 85 | let action = MicrosubTimelineAction(with: .remove, for: "channelTestName", on: ["entryId"]) 86 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 87 | let body = String(data: request.httpBody!, encoding: .utf8) 88 | 89 | XCTAssertEqual(request.httpMethod, "POST") 90 | XCTAssertNotNil(body) 91 | XCTAssertTrue(body!.contains("action=timeline")) 92 | XCTAssertTrue(body!.contains("method=remove")) 93 | XCTAssertTrue(body!.contains("channel=channelTestName")) 94 | XCTAssertTrue(body!.contains("entry=entryId")) 95 | } 96 | 97 | func testRemoveMultipleEntriesFromChannelRequest() { 98 | let action = MicrosubTimelineAction(with: .remove, for: "channelTestName", on: ["entry1", "entry2"]) 99 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 100 | let body = String(data: request.httpBody!, encoding: .utf8) 101 | 102 | XCTAssertEqual(request.httpMethod, "POST") 103 | XCTAssertNotNil(body) 104 | XCTAssertTrue(body!.contains("action=timeline")) 105 | XCTAssertTrue(body!.contains("method=remove")) 106 | XCTAssertTrue(body!.contains("channel=channelTestName")) 107 | XCTAssertTrue(body!.contains("entry[]=entry1&entry[]=entry2")) 108 | } 109 | 110 | func testSearchFeedsRequest() { 111 | let searchDomain = "eddiehinkle.com" 112 | let action = MicrosubSearchAction(query: searchDomain) 113 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 114 | let body = String(data: request.httpBody!, encoding: .utf8) 115 | 116 | XCTAssertEqual(request.httpMethod, "POST") 117 | XCTAssertNotNil(body) 118 | XCTAssertTrue(body!.contains("action=search")) 119 | XCTAssertTrue(body!.contains("query=\(searchDomain.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 120 | } 121 | 122 | func testPreviewFeedsRequest() { 123 | let previewUrl = URL(string: "https://eddiehinkle.com/timeline")! 124 | let action = MicrosubPreviewAction(url: previewUrl) 125 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 126 | let body = String(data: request.httpBody!, encoding: .utf8) 127 | 128 | XCTAssertEqual(request.httpMethod, "POST") 129 | XCTAssertNotNil(body) 130 | XCTAssertTrue(body!.contains("action=preview")) 131 | XCTAssertTrue(body!.contains("url=\(previewUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 132 | } 133 | 134 | func testFollowFeedRequest() { 135 | let followUrl = URL(string: "https://eddiehinkle.com/timeline")! 136 | let followChannel = "channelToFollowIn" 137 | 138 | let action = MicrosubChannelEffectAction(action: .follow, channel: followChannel, url: followUrl) 139 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 140 | let body = String(data: request.httpBody!, encoding: .utf8) 141 | 142 | XCTAssertEqual(request.httpMethod, "POST") 143 | XCTAssertNotNil(body) 144 | XCTAssertTrue(body!.contains("action=follow")) 145 | XCTAssertTrue(body!.contains("channel=\(followChannel)")) 146 | XCTAssertTrue(body!.contains("url=\(followUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 147 | } 148 | 149 | func testUnfollowFeedRequest() { 150 | let unfollowUrl = URL(string: "https://eddiehinkle.com/timeline")! 151 | let unfollowChannel = "channelToUnfollowIn" 152 | 153 | let action = MicrosubChannelEffectAction(action: .unfollow, channel: unfollowChannel, url: unfollowUrl) 154 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 155 | let body = String(data: request.httpBody!, encoding: .utf8) 156 | 157 | XCTAssertEqual(request.httpMethod, "POST") 158 | XCTAssertNotNil(body) 159 | XCTAssertTrue(body!.contains("action=unfollow")) 160 | XCTAssertTrue(body!.contains("channel=\(unfollowChannel)")) 161 | XCTAssertTrue(body!.contains("url=\(unfollowUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 162 | } 163 | 164 | func testGetFollowingListRequest() { 165 | let followChannel = "channelToFollowIn" 166 | 167 | let action = MicrosubChannelEffectAction(action: .follow, channel: followChannel) 168 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 169 | let body = String(data: request.httpBody!, encoding: .utf8) 170 | 171 | XCTAssertEqual(request.httpMethod, "GET") 172 | XCTAssertNotNil(body) 173 | XCTAssertTrue(body!.contains("action=follow")) 174 | XCTAssertTrue(body!.contains("channel=\(followChannel)")) 175 | } 176 | 177 | func testMuteRequest() { 178 | let muteUrl = URL(string: "https://eddiehinkle.com/timeline")! 179 | let muteChannel = "channelToMuteIn" 180 | let muteAction = MicrosubActionType.mute 181 | 182 | let action = MicrosubChannelEffectAction(action: muteAction, channel: muteChannel, url: muteUrl) 183 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 184 | let body = String(data: request.httpBody!, encoding: .utf8) 185 | 186 | XCTAssertEqual(request.httpMethod, "POST") 187 | XCTAssertNotNil(body) 188 | XCTAssertTrue(body!.contains("action=\(muteAction.rawValue)")) 189 | XCTAssertTrue(body!.contains("channel=\(muteChannel)")) 190 | XCTAssertTrue(body!.contains("url=\(muteUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 191 | } 192 | 193 | func testUnmuteFeedRequest() { 194 | let unmuteUrl = URL(string: "https://eddiehinkle.com/timeline")! 195 | let unmuteChannel = "channelToMuteIn" 196 | let unmuteAction = MicrosubActionType.unmute 197 | 198 | let action = MicrosubChannelEffectAction(action: unmuteAction, channel: unmuteChannel, url: unmuteUrl) 199 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 200 | let body = String(data: request.httpBody!, encoding: .utf8) 201 | 202 | XCTAssertEqual(request.httpMethod, "POST") 203 | XCTAssertNotNil(body) 204 | XCTAssertTrue(body!.contains("action=\(unmuteAction.rawValue)")) 205 | XCTAssertTrue(body!.contains("channel=\(unmuteChannel)")) 206 | XCTAssertTrue(body!.contains("url=\(unmuteUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 207 | } 208 | 209 | func testGetMuteListRequest() { 210 | let muteListChannel = "channelToMuteIn" 211 | let muteListAction = MicrosubActionType.mute 212 | 213 | let action = MicrosubChannelEffectAction(action: muteListAction, channel: muteListChannel) 214 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 215 | let body = String(data: request.httpBody!, encoding: .utf8) 216 | 217 | XCTAssertEqual(request.httpMethod, "GET") 218 | XCTAssertNotNil(body) 219 | XCTAssertTrue(body!.contains("action=\(muteListAction.rawValue)")) 220 | XCTAssertTrue(body!.contains("channel=\(muteListChannel)")) 221 | } 222 | 223 | func testUnblockRequest() { 224 | let unblockUrl = URL(string: "https://eddiehinkle.com/timeline")! 225 | let unblockChannel = "channelToMuteIn" 226 | let unblockAction = MicrosubActionType.block 227 | 228 | let action = MicrosubChannelEffectAction(action: unblockAction, channel: unblockChannel, url: unblockUrl) 229 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 230 | let body = String(data: request.httpBody!, encoding: .utf8) 231 | 232 | XCTAssertEqual(request.httpMethod, "POST") 233 | XCTAssertNotNil(body) 234 | XCTAssertTrue(body!.contains("action=\(unblockAction.rawValue)")) 235 | XCTAssertTrue(body!.contains("channel=\(unblockChannel)")) 236 | XCTAssertTrue(body!.contains("url=\(unblockUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 237 | } 238 | 239 | func testBlockRequest() { 240 | let blockUrl = URL(string: "https://eddiehinkle.com/timeline")! 241 | let blockChannel = "channelToMuteIn" 242 | let blockAction = MicrosubActionType.block 243 | 244 | let action = MicrosubChannelEffectAction(action: blockAction, channel: blockChannel, url: blockUrl) 245 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 246 | let body = String(data: request.httpBody!, encoding: .utf8) 247 | 248 | XCTAssertEqual(request.httpMethod, "POST") 249 | XCTAssertNotNil(body) 250 | XCTAssertTrue(body!.contains("action=\(blockAction.rawValue)")) 251 | XCTAssertTrue(body!.contains("channel=\(blockChannel)")) 252 | XCTAssertTrue(body!.contains("url=\(blockUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)")) 253 | } 254 | 255 | func testGetBlockListRequest() { 256 | let blockListChannel = "channelToMuteIn" 257 | let blockListAction = MicrosubActionType.block 258 | 259 | let action = MicrosubChannelEffectAction(action: blockListAction, channel: blockListChannel) 260 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 261 | let body = String(data: request.httpBody!, encoding: .utf8) 262 | 263 | XCTAssertEqual(request.httpMethod, "GET") 264 | XCTAssertNotNil(body) 265 | XCTAssertTrue(body!.contains("action=\(blockListAction.rawValue)")) 266 | XCTAssertTrue(body!.contains("channel=\(blockListChannel)")) 267 | } 268 | 269 | func testGetChannelRequest() { 270 | let action = MicrosubChannelAction() 271 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 272 | let body = String(data: request.httpBody!, encoding: .utf8) 273 | 274 | XCTAssertEqual(request.httpMethod, "GET") 275 | XCTAssertNotNil(body) 276 | XCTAssertTrue(body!.contains("action=channels")) 277 | XCTAssertFalse(body!.contains("name=")) 278 | XCTAssertFalse(body!.contains("channel=")) 279 | XCTAssertFalse(body!.contains("method=")) 280 | } 281 | 282 | func testCreateChannelRequest() { 283 | let channelName = "ANewChannelName" 284 | 285 | let action = MicrosubChannelAction(create: channelName) 286 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 287 | let body = String(data: request.httpBody!, encoding: .utf8) 288 | 289 | XCTAssertEqual(request.httpMethod, "POST") 290 | XCTAssertNotNil(body) 291 | XCTAssertTrue(body!.contains("action=channels")) 292 | XCTAssertTrue(body!.contains("name=\(channelName)")) 293 | XCTAssertFalse(body!.contains("channel=")) 294 | XCTAssertFalse(body!.contains("method=")) 295 | } 296 | 297 | func testUpdateChannelRequest() { 298 | let channelName = "ANewChannelName" 299 | let channelId = "theChannelId" 300 | 301 | let action = MicrosubChannelAction(update: channelId, with: channelName) 302 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 303 | let body = String(data: request.httpBody!, encoding: .utf8) 304 | 305 | XCTAssertEqual(request.httpMethod, "POST") 306 | XCTAssertNotNil(body) 307 | XCTAssertTrue(body!.contains("action=channels")) 308 | XCTAssertTrue(body!.contains("name=\(channelName)")) 309 | XCTAssertTrue(body!.contains("channel=\(channelId)")) 310 | XCTAssertFalse(body!.contains("method=")) 311 | } 312 | 313 | func testDeleteChannelRequest() { 314 | let channelId = "channelIdToRemove" 315 | 316 | let action = MicrosubChannelAction(delete: channelId) 317 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 318 | let body = String(data: request.httpBody!, encoding: .utf8) 319 | 320 | XCTAssertEqual(request.httpMethod, "POST") 321 | XCTAssertNotNil(body) 322 | XCTAssertTrue(body!.contains("action=channels")) 323 | XCTAssertTrue(body!.contains("method=delete")) 324 | XCTAssertTrue(body!.contains("channel=\(channelId)")) 325 | XCTAssertFalse(body!.contains("name=")) 326 | } 327 | 328 | func testReorderChannelsRequest() { 329 | let channels = ["channelB", "channelC", "channelA", "channelD"] 330 | 331 | let action = MicrosubChannelReorderAction(channels: channels) 332 | let request = try! action.generateRequest(for: microsubEndpoint, with: microsubAccessToken) 333 | let body = String(data: request.httpBody!, encoding: .utf8) 334 | 335 | XCTAssertEqual(request.httpMethod, "POST") 336 | XCTAssertNotNil(body) 337 | XCTAssertTrue(body!.contains("action=channels")) 338 | XCTAssertTrue(body!.contains("method=order")) 339 | XCTAssertTrue(body!.contains(channels.map { "channels[]=\($0)" }.joined(separator: "&"))) 340 | } 341 | 342 | } 343 | -------------------------------------------------------------------------------- /Tests/IndieWebKitTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(IndieAuthTests.allTests), 7 | testCase(MicropubTests.allTests), 8 | ] 9 | } 10 | #endif 11 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import IndieWebKitTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += IndieWebKitTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------