-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathBlog+SelfHosted.swift
More file actions
246 lines (206 loc) · 9.4 KB
/
Blog+SelfHosted.swift
File metadata and controls
246 lines (206 loc) · 9.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
import Foundation
import CryptoKit
import WordPressAPI
import WordPressShared
public extension Blog {
enum BlogCredentialsError: Error {
case blogUrlMissing
case blogUrlInvalid
case blogUsernameMissing
case blogPasswordMissing
case blogIdentifierMissing
case invalidCredentialsUrl
case invalidXmlRpcEndpoint
case incorrectCredentials
}
static func createRestApiBlog(
with details: WpApiApplicationPasswordDetails,
restApiRootURL: URL,
xmlrpcEndpointURL: URL,
blogID: TaggedManagedObjectID<Blog>?,
in contextManager: ContextManager,
using keychainImplementation: KeychainAccessible = KeychainUtils()
) async throws -> TaggedManagedObjectID<Blog> {
try await contextManager.performAndSave { context in
let blog = if let blogID {
try context.existingObject(with: blogID)
} else {
Blog.lookup(username: details.userLogin, xmlrpc: xmlrpcEndpointURL.absoluteString, in: context)
?? Blog.createBlankBlog(in: context)
}
blog.url = details.siteUrl
blog.username = details.userLogin
blog.restApiRootURL = restApiRootURL.absoluteString
blog.setXMLRPCEndpoint(to: xmlrpcEndpointURL)
blog.setSiteIdentifier(details.derivedSiteId)
// `url` and `xmlrpc` need to be set before setting the application password.
try blog.setApplicationToken(details.password, using: keychainImplementation)
// We don't overwrite the `Blog.password` with the application password (`details.password`), because we want
// the application continues to function when the application password is revoked.
return TaggedManagedObjectID(blog)
}
}
static func lookupRestApiBlog(with id: SiteIdentifier, in context: NSManagedObjectContext) throws -> Blog? {
try BlogQuery().apiKey(is: id).blog(in: context)
}
static func hasRestApiBlog(with id: SiteIdentifier, in context: NSManagedObjectContext) throws -> Bool {
BlogQuery().apiKey(is: id).count(in: context) != 0
}
@objc(getApplicationTokenWithError:)
func objc_getApplicationToken() throws -> String {
try getApplicationToken()
}
// MARK: Type-safe wrappers
// The underlying `Blog` object has lots of field nullability that doesn't provide guarantees about
// which fields are present. These wrappers will `throw` if the `Blog` is invalid, allowing any dependent
// code can be much simpler.
/// Retrieve Application Tokens
///
func getApplicationToken(using keychainImplementation: KeychainAccessible = KeychainUtils()) throws -> String {
try keychainImplementation.getPassword(for: self.getUsername(), serviceName: self.getUrlString())
}
/// Delete Application Token
///
func deleteApplicationToken(using keychainImplementation: KeychainAccessible = KeychainUtils()) throws {
try? keychainImplementation.setPassword(for: self.getUsername(), to: nil, serviceName: self.getUrlString())
}
@available(swift, obsoleted: 1.0)
@objc(deleteApplicationToken)
func objc_deleteApplicationToken() {
_ = try? deleteApplicationToken()
}
/// Store Application Tokens
///
func setApplicationToken(
_ newValue: String,
using keychainImplementation: KeychainAccessible = KeychainUtils()
) throws {
try keychainImplementation.setPassword(for: self.getUsername(), to: newValue, serviceName: self.getUrlString())
}
/// A null-safe wrapper for `Blog.username`
func getUsername() throws -> String {
guard let username = self.username else {
throw BlogCredentialsError.blogUsernameMissing
}
return username
}
/// A null-safe replacement for `Blog.password(get)`
func getPassword(using keychainImplementation: KeychainAccessible = KeychainUtils()) throws -> String {
try keychainImplementation.getPassword(for: self.getUsername(), serviceName: self.getXMLRPCEndpoint().absoluteString)
}
/// A null-safe replacement for `Blog.password(set)`
func setPassword(to newValue: String, using keychainImplementation: KeychainAccessible = KeychainUtils()) throws {
try keychainImplementation.setPassword(for: self.getUsername(), to: newValue, serviceName: self.getXMLRPCEndpoint().absoluteString)
}
func wordPressClientParsedUrl() throws -> ParsedUrl {
try ParsedUrl.parse(input: self.getUrl().absoluteString)
}
/// A null-and-type-safe replacement for `Blog.url(get)`
func getUrl() throws -> URL {
guard let stringUrl = self.url else {
throw BlogCredentialsError.blogUrlMissing
}
guard let url = URL(string: stringUrl) else {
throw BlogCredentialsError.blogUrlInvalid
}
return url
}
/// A null-safe helper for `Blog.url(get)`, when what you really want is a String
func getUrlString() throws -> String {
try getUrl().absoluteString
}
/// A type-safe helper for `Blog.url(set)` that takes a URL directly (instead of a string)
func setUrl(_ newValue: URL) {
self.url = newValue.absoluteString
}
/// A null-and-type-safe replacement for `Blog.xmlrpc(get)`
func getXMLRPCEndpoint() throws -> URL {
guard let urlString = self.xmlrpc, let url = URL(string: urlString) else {
throw BlogCredentialsError.invalidXmlRpcEndpoint
}
return url
}
/// A type-safe helper for `Blog.xmlrpc(set)` that takes a URL directly (instead of a string)
func setXMLRPCEndpoint(to newValue: URL) {
self.xmlrpc = newValue.absoluteString
}
/// There's `dotComId` for WordPress.com blogs, but we don't have a good way to lookup REST API sites with a scalar value.
///
/// This hack fixes that – we should never store API Keys in Core Data anyway, so we can (mis)use that field to add a unique identifier
typealias SiteIdentifier = String
func getSiteIdentifier() throws -> SiteIdentifier {
guard let identifier = self.apiKey else {
throw BlogCredentialsError.blogIdentifierMissing
}
return identifier
}
func setSiteIdentifier(_ newValue: SiteIdentifier) {
self.apiKey = newValue
}
@objc var isSelfHosted: Bool {
self.account == nil
}
@objc var supportsCoreRestApi: Bool {
if case .selfHosted = try? WordPressSite(blog: self) {
return true
}
return false
}
}
public extension WpApiApplicationPasswordDetails {
var derivedSiteId: String {
SHA256.hash(data: Data(siteUrl.localizedLowercase.utf8))
.compactMap { String(format: "%02x", $0) }
.joined()
}
}
public enum WordPressSite {
case dotCom(siteId: Int, authToken: String)
case selfHosted(blogId: TaggedManagedObjectID<Blog>, apiRootURL: ParsedUrl, username: String, authToken: String)
public init(blog: Blog) throws {
// Directly access the site content when available.
if let restApiRootURL = blog.restApiRootURL,
let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL),
let username = blog.username,
let authToken = try? blog.getApplicationToken() {
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: restApiRootURL, username: username, authToken: authToken)
} else if let account = blog.account, let siteId = blog.dotComID?.intValue {
// When the site is added via a WP.com account, access the site via WP.com
let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username)
self = .dotCom(siteId: siteId, authToken: authToken)
} else {
// In theory, this branch should never run, because the two if statements above should have covered all paths.
// But we'll keep it here as the fallback.
let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString
let apiRootURL = try ParsedUrl.parse(input: url)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
}
}
public static func throughDotCom(blog: Blog) -> Self? {
guard
let account = blog.account,
let siteId = blog.dotComID?.intValue,
let authToken = try? account.authToken ?? WPAccount.token(forUsername: account.username)
else { return nil }
return .dotCom(siteId: siteId, authToken: authToken)
}
public func blog(in context: NSManagedObjectContext) throws -> Blog? {
switch self {
case let .dotCom(siteId, _):
return try Blog.lookup(withID: siteId, in: context)
case let .selfHosted(blogId, _, _, _):
return try context.existingObject(with: blogId)
}
}
public func blogId(in coreDataStack: CoreDataStack) -> TaggedManagedObjectID<Blog>? {
switch self {
case let .dotCom(siteId, _):
return coreDataStack.performQuery { context in
guard let blog = try? Blog.lookup(withID: siteId, in: context) else { return nil }
return TaggedManagedObjectID(blog)
}
case let .selfHosted(id, _, _, _):
return id
}
}
}