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
12 changes: 8 additions & 4 deletions Sources/WordPressData/Swift/Blog+Features.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ extension Blog {
case .customThemes:
return supportsRestAPI && isAdmin && !isHostedAtWPcom
case .premiumThemes:
return supports(.customThemes) && (planID?.intValue == Self.jetpackProfessionalYearlyPlanId || planID?.intValue == Self.jetpackProfessionalMonthlyPlanId)
return supports(.customThemes)
&& (planID?.intValue == Self.jetpackProfessionalYearlyPlanId
|| planID?.intValue == Self.jetpackProfessionalMonthlyPlanId)
case .private:
return isHostedAtWPcom
case .sharing:
Expand All @@ -107,7 +109,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 Expand Up @@ -192,7 +194,8 @@ private extension Blog {
return true
}
if account == nil && !isHostedAtWPcom && selfHostedSiteRestApi != nil
&& hasRequiredWordPressVersion("5.5") {
&& hasRequiredWordPressVersion("5.5")
{
return true
}
return false
Expand All @@ -207,7 +210,8 @@ private extension Blog {

func hasRequiredJetpackVersion(_ requiredVersion: String) -> Bool {
guard supportsRestAPI, !isHostedAtWPcom,
let version = jetpack?.version else {
let version = jetpack?.version
else {
return false
}
return version.compare(requiredVersion, options: .numeric) != .orderedAscending
Expand Down
211 changes: 158 additions & 53 deletions Sources/WordPressData/Swift/Blog+SelfHosted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ public extension Blog {
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)
}
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
Expand Down Expand Up @@ -104,12 +105,19 @@ public extension Blog {

/// 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)
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)
try keychainImplementation.setPassword(
for: self.getUsername(),
to: newValue,
serviceName: self.getXMLRPCEndpoint().absoluteString
)
}

func wordPressClientParsedUrl() throws -> ParsedUrl {
Expand Down Expand Up @@ -174,11 +182,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 +198,155 @@ 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
}
}
}

public init(blog: Blog) throws {
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, keychain: KeychainAccessible = KeychainUtils()) 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 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 parsedApiRoot = try? ParsedUrl.parse(input: restApiRootURL),
let username = blog.username,
let token = try? blog.getApplicationToken(using: keychain)
{
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
Loading