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
8 changes: 4 additions & 4 deletions Modules/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ let package = Package(
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.15.0"),
// We can't use wordpress-rs branches nor commits here. Only tags work.
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260313.1"),
.package(url: "https://github.com/automattic/wordpress-rs", branch: "pr-build/1239"),
.package(
url: "https://github.com/Automattic/color-studio",
revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de"
Expand Down
2 changes: 1 addition & 1 deletion Modules/Sources/WordPressCore/Users/UserService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public actor UserService: UserServiceProtocol {
}

public func isCurrentUserCapableOf(_ capability: UserCapability) async -> Bool {
await currentUser?.capabilities.keys.contains(capability) == true
await currentUser?.capabilities.hasCap(capability: capability) == true
}

public func deleteUser(id: Int64, reassigningPostsTo newUserId: Int64) async throws {
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/WordPressCore/WordPressClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public protocol WordPressClientAPI: Sendable {
var postTypes: PostTypesRequestExecutor { get }
var siteSettings: SiteSettingsRequestExecutor { get }

func createSelfHostedService(cache: WordPressApiCache) throws -> WpService
func createService(cache: WordPressApiCache) throws -> WpService

func uploadMedia(
params: MediaCreateParams,
Expand Down Expand Up @@ -99,7 +99,7 @@ public actor WordPressClient {
if let _service {
return _service
}
let service = try api.createSelfHostedService(cache: cache)
let service = try api.createService(cache: cache)
_service = service
return service
}
Expand Down
25 changes: 18 additions & 7 deletions Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable {
var posts: PostsRequestExecutor { fatalError("Not implemented") }
var postTypes: PostTypesRequestExecutor { fatalError("Not implemented") }

func createSelfHostedService(cache: WordPressApiCache) throws -> WpService {
func createService(cache: WordPressApiCache) throws -> WpService {
fatalError("Not implemented")
}

func uploadMedia(params: MediaCreateParams, fulfilling progress: Progress) async throws -> MediaRequestCreateResponse {
func uploadMedia(
params: MediaCreateParams,
fulfilling progress: Progress
) async throws -> MediaRequestCreateResponse {
fatalError("Not implemented")
}
}
Expand Down Expand Up @@ -118,7 +121,9 @@ final class MockUsersRequestExecutor: UsersRequestExecutor {
super.init(unsafeFromHandle: handle)
}

override func retrieveMeWithEditContextCancellation(context: RequestContext?) async throws -> UsersRequestRetrieveMeWithEditContextResponse {
override func retrieveMeWithEditContextCancellation(
context: RequestContext?
) async throws -> UsersRequestRetrieveMeWithEditContextResponse {
let mockUser = UserWithEditContext(
id: UserId(1),
username: "testuser",
Expand All @@ -134,7 +139,7 @@ final class MockUsersRequestExecutor: UsersRequestExecutor {
slug: "testuser",
registeredDate: "2024-01-01T00:00:00",
roles: [],
capabilities: [:],
capabilities: UserCapabilitiesMap(map: [:]),
extraCapabilities: [:],
avatarUrls: nil
)
Expand All @@ -156,7 +161,10 @@ final class MockThemesRequestExecutor: ThemesRequestExecutor {
super.init(unsafeFromHandle: handle)
}

override func listWithEditContextCancellation(params: ThemeListParams, context: RequestContext?) async throws -> ThemesRequestListWithEditContextResponse {
override func listWithEditContextCancellation(
params: ThemeListParams,
context: RequestContext?
) async throws -> ThemesRequestListWithEditContextResponse {
let mockTheme = ThemeWithEditContext(
stylesheet: ThemeStylesheet(value: "twentytwentyfour"),
template: "twentytwentyfour",
Expand Down Expand Up @@ -196,7 +204,9 @@ final class MockSiteSettingsRequestExecutor: SiteSettingsRequestExecutor {
super.init(unsafeFromHandle: handle)
}

override func retrieveWithEditContextCancellation(context: RequestContext?) async throws -> SiteSettingsRequestRetrieveWithEditContextResponse {
override func retrieveWithEditContextCancellation(
context: RequestContext?
) async throws -> SiteSettingsRequestRetrieveWithEditContextResponse {
let mockSettings = SiteSettingsWithEditContext(
title: "Test Site",
description: "A test site",
Expand All @@ -217,7 +227,8 @@ final class MockSiteSettingsRequestExecutor: SiteSettingsRequestExecutor {
defaultPingStatus: .open,
defaultCommentStatus: .open,
siteLogo: nil,
siteIcon: 0
siteIcon: 0,
additionalFields: AnyJson(noHandle: AnyJson.NoHandle())
)
let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle())
return SiteSettingsRequestRetrieveWithEditContextResponse(data: mockSettings, headerMap: mockHeaderMap)
Expand Down
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)
}
}
Loading