Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/WordPressData/Swift/Blog+Features.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ extension Blog {
// alt is not supported via XML-RPC API
// https://core.trac.wordpress.org/ticket/58582
// https://github.com/wordpress-mobile/WordPress-Android/issues/18514#issuecomment-1589752274
return supportsRestAPI || supportsCoreRestApi
return supportsRestAPI || hasDirectCoreRESTAPIAccess
case .contactInfo:
return hasRequiredJetpackVersion("8.5") || isHostedAtWPcom
case .blockEditorSettings:
Expand Down
178 changes: 135 additions & 43 deletions Sources/WordPressData/Swift/Blog+SelfHosted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,11 @@ public extension Blog {
self.account == nil
}

@objc var supportsCoreRestApi: Bool {
if case .selfHosted = try? WordPressSite(blog: self) {
return true
@objc var hasDirectCoreRESTAPIAccess: Bool {
guard let site = try? WordPressSite(blog: self) else {
return false
}
return false
return site.applicationPasswordCredentials != nil
}
}

Expand All @@ -190,58 +190,150 @@ public extension WpApiApplicationPasswordDetails {
}
}

public enum WordPressSite: Hashable {
case dotCom(siteURL: URL, siteId: Int, authToken: String)
case selfHosted(blogId: TaggedManagedObjectID<Blog>, siteURL: URL, apiRootURL: ParsedUrl, username: String, authToken: String)
/// Describes a WordPress site's hosting type, authentication credentials,
/// and API capabilities.
///
/// This is a value type constructed from a `Blog` Core Data object. It captures
/// a snapshot of the site's characteristics at construction time.
///
/// `WordPressSite` is not a one-to-one mapping with `Blog`. It represents the
/// subset of `Blog` instances that have access to the WordPress core REST API
/// (wp/v2). A self-hosted site that only has XML-RPC credentials is not
/// representable as a `WordPressSite`.
///
/// - All WordPress.com sites qualify (wp/v2 is accessed via WP.com REST API
/// with OAuth).
/// - Self-hosted sites must have application password credentials.
public struct WordPressSite {
public let blogId: TaggedManagedObjectID<Blog>
public let siteURL: URL
public let flavor: ApiFlavor

public init(blogId: TaggedManagedObjectID<Blog>, siteURL: URL, flavor: ApiFlavor) {
self.blogId = blogId
self.siteURL = siteURL
self.flavor = flavor
}
}

extension WordPressSite {
public enum ApiFlavor {
/// A site hosted on WordPress.com. Always has OAuth access via
/// WPAccount. May also have application password credentials
/// (e.g., Atomic sites).
case dotCom(DotComCredentials)

/// A self-hosted WordPress site with application password credentials.
/// Application password is required for wp/v2 API access.
case selfHosted(ApplicationPasswordCredentials)
}
}

extension WordPressSite {
public struct DotComCredentials: Hashable {
public let siteId: Int
public let oAuthToken: String
/// Non-nil for Atomic sites that also have application password access.
public let applicationPassword: ApplicationPasswordCredentials?

public init(siteId: Int, oAuthToken: String, applicationPassword: ApplicationPasswordCredentials?) {
self.siteId = siteId
self.oAuthToken = oAuthToken
self.applicationPassword = applicationPassword
}
}

public struct ApplicationPasswordCredentials: Hashable {
public let apiRootURL: ParsedUrl
public let username: String
public let token: String

public init(apiRootURL: ParsedUrl, username: String, token: String) {
self.apiRootURL = apiRootURL
self.username = username
self.token = token
}
}
}

extension WordPressSite: Hashable {
public static func == (lhs: WordPressSite, rhs: WordPressSite) -> Bool {
lhs.blogId == rhs.blogId
}

public func hash(into hasher: inout Hasher) {
hasher.combine(blogId)
}
}

extension WordPressSite {
/// Constructs a `WordPressSite` from a `Blog` Core Data object.
///
/// Throws if the blog lacks enough data to determine its hosting type
/// and at least one valid authentication method.
///
/// For self-hosted sites, application password credentials are required.
/// Sites without them cannot be represented as a `WordPressSite`.
public init(blog: Blog) throws {
let siteURL = try blog.getUrl()
// Directly access the site content when available.
self.blogId = TaggedManagedObjectID(blog)
self.siteURL = siteURL

// Build application password credentials if available.
// These are shared across both hosting types — WordPress.com Atomic
// sites can have them too.
let applicationPassword: ApplicationPasswordCredentials?
if let restApiRootURL = blog.restApiRootURL,
let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL),
let parsedApiRoot = try? ParsedUrl.parse(input: restApiRootURL),
let username = blog.username,
let authToken = try? blog.getApplicationToken() {
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: siteURL, 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(siteURL: siteURL, siteId: siteId, authToken: authToken)
let token = try? blog.getApplicationToken() {
applicationPassword = ApplicationPasswordCredentials(
apiRootURL: parsedApiRoot,
username: username,
token: token
)
} 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.getUrl()
let apiRootURL = try ParsedUrl.parse(input: blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: url, apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
applicationPassword = nil
}
}

public var siteURL: URL {
switch self {
case let .dotCom(siteURL, _, _):
return siteURL
case let .selfHosted(_, siteURL, _, _, _):
return siteURL
// Check for WordPress.com account first. This means Atomic sites
// (which have both an account and application password credentials)
// resolve to `.dotCom`.
if let account = blog.account,
let siteId = blog.dotComID?.intValue {
let authToken = try account.authToken
?? WPAccount.token(forUsername: account.username)
self.flavor = .dotCom(DotComCredentials(
siteId: siteId,
oAuthToken: authToken,
applicationPassword: applicationPassword
))
} else {
// Self-hosted sites must have application password credentials
// for wp/v2 API access.
guard let applicationPassword else {
throw Blog.BlogCredentialsError.blogPasswordMissing
}
self.flavor = .selfHosted(applicationPassword)
}
}
}

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)
extension WordPressSite {
/// The application password credentials, if available.
/// Always non-nil for self-hosted sites. Optional for WordPress.com sites
/// (non-nil for Atomic sites).
public var applicationPasswordCredentials: ApplicationPasswordCredentials? {
switch flavor {
case let .dotCom(credentials):
return credentials.applicationPassword
case let .selfHosted(credentials):
return credentials
}
}

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
}
/// Look up the `Blog` object in a given Core Data context.
public func blog(in context: NSManagedObjectContext) throws -> Blog {
try context.existingObject(with: blogId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class UserListViewModelTests: XCTestCase {
),
authentication: .none
)
let client = try WordPressClient(
let client = WordPressClient(
api: api,
siteURL: URL(string: "https://example.com")!
)
Expand Down
6 changes: 3 additions & 3 deletions WordPress/Classes/Login/ApplicationPasswordRequiredView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,9 @@ struct ApplicationPasswordRequiredView<Content: View>: View {
}

private func updateSite() {
// We check that the site is `selfHosted` to ensure an _Application Password_ is available. That's what this view
// is for, after all.
if let site = try? WordPressSite(blog: blog), case .selfHosted = site {
// We check that the site has application password credentials to ensure
// direct wp/v2 API access is available. That's what this view is for.
if let site = try? WordPressSite(blog: blog), site.applicationPasswordCredentials != nil {
self.site = site
}
}
Expand Down
16 changes: 8 additions & 8 deletions WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ struct SelfHostedSiteAuthenticator {
let result = try await handle(
credentials: credentials,
apiRootURL: apiRootURL,
apiDetails: details.apiDetails,
apiDiscovery: details,
context: context
)
trackSuccess(url: details.parsedSiteUrl.url())
Expand Down Expand Up @@ -279,7 +279,7 @@ struct SelfHostedSiteAuthenticator {
private func handle(
credentials: WpApiApplicationPasswordDetails,
apiRootURL: URL,
apiDetails: WpApiDetails,
apiDiscovery: AutoDiscoveryAttemptSuccess,
context: SignInContext
) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
SVProgressHUD.show()
Expand All @@ -294,7 +294,7 @@ struct SelfHostedSiteAuthenticator {
let blog = try await createSite(
credentials: credentials,
apiRootURL: apiRootURL,
apiDetails: apiDetails,
apiDiscovery: apiDiscovery,
context: context
)

Expand Down Expand Up @@ -345,7 +345,7 @@ struct SelfHostedSiteAuthenticator {
private func createSite(
credentials: WpApiApplicationPasswordDetails,
apiRootURL: URL,
apiDetails: WpApiDetails,
apiDiscovery: AutoDiscoveryAttemptSuccess,
context: SignInContext
) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
// We still need to set the `Blog.xmlrpc`, because it's used all across the app.
Expand All @@ -359,8 +359,8 @@ struct SelfHostedSiteAuthenticator {
let api = WordPressAPI(
urlSession: URLSession(configuration: .ephemeral),
siteInfo: .selfHosted(
siteUrl: try! ParsedUrl.parse(input: credentials.siteUrl),
apiRoot: try! ParsedUrl.parse(input: apiRootURL.absoluteString)
siteUrl: apiDiscovery.parsedSiteUrl,
apiRoot: apiDiscovery.apiRootUrl
),
authentication: WpAuthentication(username: credentials.userLogin, password: credentials.password)
)
Expand Down Expand Up @@ -429,12 +429,12 @@ struct SelfHostedSiteAuthenticator {
blog.setValue(timezone, forOption: "timezone")
}

if blog.getOptionString(name: "gmt_offset") == nil, let offset = apiDetails.gmtOffset() {
if blog.getOptionString(name: "gmt_offset") == nil, let offset = apiDiscovery.apiDetails.gmtOffset() {
blog.setValue(offset, forOption: "gmt_offset")
}

if blog.getOptionString(name: "home_url") == nil {
blog.setValue(apiDetails.homeUrlString(), forOption: "home_url")
blog.setValue(apiDiscovery.apiDetails.homeUrlString(), forOption: "home_url")
}
}

Expand Down
40 changes: 18 additions & 22 deletions WordPress/Classes/Networking/WordPressClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ extension WordPressClient {
.init("WordPressClient.requestedWithInvalidAuthenticationNotification")
}

fileprivate convenience init(site: WordPressSite) {
fileprivate init(site: WordPressSite) {
// Currently, the app supports both account passwords and application passwords.
// When a site is initially signed in with an account password, WordPress login cookies are stored
// in `URLSession.shared`. After switching the site to application password authentication,
Expand All @@ -55,15 +55,12 @@ extension WordPressClient {
coreDataStack: ContextManager.shared
)
)
let siteURL: URL
let siteInfo: SiteInfo
switch site {
case let .dotCom(url, siteId, _):
siteURL = url
siteInfo = .wordPressCom(siteId: WpComSiteId(siteId))
case let .selfHosted(_, url, apiRoot, _, _):
siteURL = url
siteInfo = .selfHosted(siteUrl: try! ParsedUrl.from(url: url), apiRoot: apiRoot)
switch site.flavor {
case let .dotCom(credentials):
siteInfo = .wordPressCom(siteId: WpComSiteId(credentials.siteId))
case let .selfHosted(credentials):
siteInfo = .selfHosted(siteUrl: try! ParsedUrl.from(url: site.siteURL), apiRoot: credentials.apiRootURL)
}
let api = WordPressAPI(
urlSession: session,
Expand All @@ -72,7 +69,7 @@ extension WordPressClient {
authenticationProvider: provider,
appNotifier: notifier,
)
self.init(api: api, siteURL: siteURL)
self.init(api: api, siteURL: site.siteURL)
}

func installJetpack() async throws -> PluginWithEditContext {
Expand Down Expand Up @@ -107,11 +104,11 @@ private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDyn
self.site = site
self.coreDataStack = coreDataStack
self.authentication =
switch site {
case let .dotCom(_, _, authToken):
.bearer(token: authToken)
case let .selfHosted(_, _, _, username, authToken):
.init(username: username, password: authToken)
switch site.flavor {
case let .dotCom(credentials):
.bearer(token: credentials.oAuthToken)
case let .selfHosted(credentials):
.init(username: credentials.username, password: credentials.token)
}

self.cancellable = NotificationCenter.default
Expand Down Expand Up @@ -146,7 +143,7 @@ private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDyn
}

func refresh() async -> Bool {
guard let blogId = site.blogId(in: coreDataStack) else { return false }
let blogId = site.blogId

do {
DDLogInfo("Create a new application password")
Expand All @@ -172,25 +169,24 @@ private class AppNotifier: @unchecked Sendable, WpAppNotifier {
}

func requestedWithInvalidAuthentication(requestUrl: String) async {
let blogId = site.blogId(in: coreDataStack)
NotificationCenter.default.post(
name: WordPressClient.requestedWithInvalidAuthenticationNotification,
object: blogId
object: site.blogId
)
}
}

private extension WordPressSite {
func authentication(in context: NSManagedObjectContext) -> WpAuthentication {
switch self {
case let .dotCom(_, siteId, _):
guard let blog = try? Blog.lookup(withID: siteId, in: context),
switch self.flavor {
case .dotCom:
guard let blog = try? context.existingObject(with: blogId),
let token = blog.account?.authToken
else {
return WpAuthentication.none
}
return WpAuthentication.bearer(token: token)
case let .selfHosted(blogId, _, _, _, _):
case .selfHosted:
guard let blog = try? context.existingObject(with: blogId),
let username = try? blog.getUsername(),
let password = try? blog.getApplicationToken()
Expand Down
Loading