├── .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 | 
3 | [](https://github.com/apple/swift-package-manager)
4 | 
5 | [](https://travis-ci.org/EdwardHinkle/IndieWebKit)
6 | [](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 |
--------------------------------------------------------------------------------