Skip to content
Merged
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
46 changes: 46 additions & 0 deletions Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,46 @@ open class WordPressOrgXMLRPCApi: NSObject, WordPressOrgXMLRPCApiInterfacing {
let parameters: [AnyObject] = [0 as AnyObject, username as AnyObject, password as AnyObject]
callMethod("wp.getOptions", parameters: parameters, success: success, failure: failure)
}

public func isXMLRPCAvailable(username: String, password: String) async -> XMLRPCAvailability {
let parameters: [AnyObject] = [0 as AnyObject, username as AnyObject, password as AnyObject]
let result = await call(method: "wp.getOptions", parameters: parameters)
guard case let .failure(error) = result else { return .available }

switch error {
// This is the most ideal error case, where the site sent an HTTP 200 response with an "fault" XML.
case let .endpointError(fault):
// 405 is a proper fault code that indicates XML-RPC is disabled.
return fault.code == 405 ? .unavailable : .available

// This error means the site sends an non-200 status code, which can mean anything.
case let .unacceptableStatusCode(response, _):
if response.statusCode == 404 {
return .unavailable
}

// If the response is not an XML, we'll treat it as disabled. Some plugin does this.
if response.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/xml") == false {
return .unavailable
}

return .unknown

// The site returned an HTTP 200 with an response that we can't parse (which is likely not xml).
case let .unparsableResponse(response, _, _):
if response?.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/xml") == false {
return .unavailable
}
return .unknown

// Treat the following errors as unknown, because we don't know for certain in these cases.
// The `connection` error (failing to send the request or receive the response) is mostly likely
// to be the only possible case here.
case .connection, .requestEncodingFailure, .unknown:
return .unknown
}
}

/**
Executes a XMLRPC call for the method specificied with the arguments provided.

Expand Down Expand Up @@ -437,3 +477,9 @@ private extension WordPressAPIError where EndpointError == WordPressOrgXMLRPCApi
}

}

public enum XMLRPCAvailability: Equatable {
case available
case unavailable
case unknown
}
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
26.7
-----
* [**] Stats: Add new "Adds" tab to show WordAdds earnings and stats [#25165]
* [*] For sites that are authenticated with application passwords, the app no longer requires xml-rpc to be enabled. But in that scenario, only limited features are available: XML-RPC needs to be enabled to use all features. [#25183]
* [**] Stats: Add "Devices" view to the "Traffic" tab [#25176]
* [**] Stats: Add "UTM" view to the "Traffic" tab [#25178]
* [*] Stats: Fix a few issues with charts (x axis labels alignment, occasionally incorrect displayed date period, and more) [#25185]
Expand Down
8 changes: 7 additions & 1 deletion Sources/WordPressData/Objective-C/Blog.m
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,13 @@ - (NSString *)version

- (NSString *)password
{
return [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:self.xmlrpc accessGroup:nil error:nil];
NSString *accountPassword = [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:self.xmlrpc accessGroup:nil error:nil];
if (accountPassword != nil) {
return accountPassword;
}

// Application password can also be used to authenticate XML-RPC.
return [self getApplicationTokenWithError:nil];
}

- (void)setPassword:(NSString *)password
Expand Down
5 changes: 5 additions & 0 deletions Sources/WordPressData/Swift/Blog+SelfHosted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public extension Blog {
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
Expand Down
85 changes: 78 additions & 7 deletions WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,23 @@ struct SelfHostedSiteAuthenticator {
throw .mismatchedUser(expectedUsername: username)
}

let blog = try await fetchSiteDataUsingCoreRESTAPI(credentials: credentials, apiRootURL: apiRootURL, context: context)

switch context {
case .default:
NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: WordPressAuthenticator.WPSigninDidFinishNotification), object: nil)
case .reauthentication:
NotificationCenter.default.post(name: Self.applicationPasswordUpdated, object: nil)
}

return blog
}

private func fetchSiteDataUsingXMLRPC(
credentials: WpApiApplicationPasswordDetails,
apiRootURL: URL,
context: SignInContext
) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
let xmlrpc: URL = try await discoverXMLRPCEndpoint(site: credentials.siteUrl)
let blogOptions: [AnyHashable: Any]
do {
Expand Down Expand Up @@ -235,13 +252,6 @@ struct SelfHostedSiteAuthenticator {
}
}

switch context {
case .default:
NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: WordPressAuthenticator.WPSigninDidFinishNotification), object: nil)
case .reauthentication:
NotificationCenter.default.post(name: Self.applicationPasswordUpdated, object: nil)
}

return blog
}

Expand Down Expand Up @@ -272,4 +282,65 @@ struct SelfHostedSiteAuthenticator {
}
}

// This is an alternative to `fetchSiteDataUsingXMLRPC`, without requiring the site's XML-RPC to be enabled.
private func fetchSiteDataUsingCoreRESTAPI(
credentials: WpApiApplicationPasswordDetails,
apiRootURL: URL,
context: SignInContext
) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
let api = WordPressAPI(
urlSession: URLSession(configuration: .ephemeral),
apiRootUrl: try! ParsedUrl.parse(input: apiRootURL.absoluteString),
authentication: WpAuthentication(username: credentials.userLogin, password: credentials.password)
)

let siteTitle: String
let isAdmin: Bool
do {
async let settingsResponse = api.siteSettings.retrieveWithViewContext()
async let userResponse = api.users.retrieveMeWithEditContext()
let (settings, user) = try await (settingsResponse.data, userResponse.data)

isAdmin = user.roles.contains(.administrator)
siteTitle = settings.title
} catch {
throw .loadingSiteInfoFailure
}

// If the XMLRPC is disabled, we'll use the default endpoint.
let xmlrpc = (try? await discoverXMLRPCEndpoint(site: credentials.siteUrl))
?? URL(string: credentials.siteUrl)?.appending(component: "xmlrpc.php")
guard let xmlrpc else {
throw .loadingSiteInfoFailure
}

// FIXME: The XML-RPC version stores `wp.getOptions` result in `Blog.options`, which is used in a few places
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how accurate this is, but I did the CC check for this:

 Based on my search, here's a comprehensive analysis of what getOptionValue is used for and the impact of missing Blog.options:

  Current Usage of getOptionValue in the App

  The Blog.options dictionary (populated via XML-RPC's wp.getOptions) is used for 22 different settings across the codebase:

  Critical Features (High Impact)

  1. Jetpack Integration (BlogSyncFacade.m:69, Blog.m:797)
  - jetpack_user_login - Links Jetpack site to WordPress.com account
  - jetpack_active_modules - Determines which Jetpack features are available
  - jetpack_connection_active_plugins - Identifies active Jetpack plugins

  2. Site Type Detection (Blog.m:166-180)
  - is_wpcom_atomic - Atomic site detection
  - is_wpforteams_site - WP for Teams detection
  - is_automated_transfer - Automated Transfer detection
  - These affect feature availability and UI throughout the app

  3. Media Upload (Blog+Quota.swift:67)
  - max_upload_size - Maximum file upload size
  - Without this, uploads may fail or the app can't show size limits

  4. Image Resizing (Blog.m:400-405)
  - thumbnail_size_w/h, medium_size_w/h, large_size_w/h
  - Used to match site's configured image sizes
  - Fallbacks to defaults if missing

  Moderate Impact

  5. URL Construction (Blog.m:215, 244, 265, 290)
  - home_url - Site home URL (falls back to blog.url)
  - login_url - Login page URL (falls back to /wp-login.php)
  - admin_url - Admin dashboard URL (falls back to /wp-admin/)
  - unmapped_url - For custom domain mapping detection

  6. Post Previews (PreviewNonceHandler.swift:24, 39)
  - frame_nonce - Security nonce for previewing posts
  - unmapped_url - Needed for preview URL rewriting
  - May break post preview functionality

  7. Timezone (Blog.m:307-309)
  - timezone, gmt_offset, time_zone - Site timezone configuration
  - Used for displaying dates/times correctly

  8. Feature Capabilities (Blog.m:488, 493, 622)
  - can_blaze - Blaze promotional campaigns availability
  - post_thumbnail - Featured images support (has fallback)
  - publicize_disabled - Social sharing availability

  9. WordPress Version (Blog.m:429)
  - software_version - WordPress version number
  - May affect feature compatibility checks

  ---
  What's Available from REST API

  The WordPress REST API /wp/v2/settings endpoint provides only 20 fields:
  - ✅ title, description, url, email
  - ✅ timezone, dateFormat, timeFormat, startOfWeek
  - ✅ language, postsPerPage
  - ✅ defaultCategory, defaultPostFormat
  - ✅ useSmilies, showOnFront, pageOnFront, pageForPosts
  - ✅ defaultPingStatus, defaultCommentStatus
  - ✅ siteLogo, siteIcon

  Missing from REST API (18 fields)

  ❌ All Jetpack-related fields
  ❌ All site type detection flags (Atomic, WP for Teams, etc.)
  ❌ All image size settings
  ❌ max_upload_size
  ❌ frame_nonce (for previews)
  ❌ unmapped_url (for custom domains)
  ❌ can_blaze
  ❌ login_url, admin_url, home_url
  ❌ software_version

  ---
  Impact Analysis for This PR

  Features That Will Break

  1. Post Previews - Missing frame_nonce may cause preview failures
  2. Jetpack Features - Can't detect active modules or link to WP.com account
  3. Media Upload Limits - No way to know max upload size
  4. Custom Domain Sites - Missing unmapped_url for URL rewriting

  Features That Will Degrade

  1. Image Resizing - Falls back to hardcoded defaults
  2. Timezone Display - Partial support via REST API timezone field
  3. URL Construction - Falls back to default paths
  4. Blaze - Can't determine availability
  5. Site Type Detection - Can't identify Atomic/WP for Teams sites

  Features That Work

  1. Basic site info (title, description, URL)
  2. WordPress.com sites (don't use XML-RPC options)
  3. Most core functionality that doesn't depend on these settings

  ---
  Recommendations

  Option 1: Fetch Additional Data via REST API
  Some missing fields might be available through other REST API endpoints:
  - Check /wp/v2/media endpoint for upload size limits
  - Check /jetpack/v4/* endpoints for Jetpack data
  - Check site metadata endpoints for version info

  Option 2: Graceful Degradation
  Add fallbacks for critical features:
  - Show warning when previewing posts without nonce
  - Default to conservative upload size limits
  - Disable Jetpack-specific features

  Option 3: Optional XML-RPC
  Allow the app to make individual XML-RPC calls even when XML-RPC is "disabled":
  - Some security plugins block certain methods but not wp.getOptions
  - Try fetching options and fail gracefully if blocked

  Option 4: Feature Documentation
  Document exactly which features are unavailable when XML-RPC is disabled and show this to users in the alert/modal.

  ---
  The FIXME at SelfHostedSiteAuthenticator.swift:317-319 is well-placed - this is a significant limitation that affects multiple features, particularly
  Jetpack integration, media uploads, and post previews.

// in the app. We can't get the same "options" via REST API. We'll need to investigate the impact of missing
// "options".

let blog: TaggedManagedObjectID<Blog>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) should probably be named blogID

do {
blog = try await Blog.createRestApiBlog(
with: credentials,
restApiRootURL: apiRootURL,
xmlrpcEndpointURL: xmlrpc,
blogID: context.blogID,
in: ContextManager.shared
)

try await ContextManager.shared.performAndSave { context in
let blog = try context.existingObject(with: blog)

blog.isAdmin = isAdmin
blog.addSettingsIfNecessary()
blog.settings?.name = siteTitle
}

try await ApplicationPasswordRepository.shared.saveApplicationPassword(of: blog)
} catch {
throw .savingSiteFailure
}

return blog
}
}
1 change: 1 addition & 0 deletions WordPress/Classes/Services/Facades/BlogSyncFacade.m
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ - (void)syncBlogWithUsername:(NSString *)username
blog.url = url;
}
if (blogName) {
[blog addSettingsIfNecessary];
blog.settings.name = [blogName stringByDecodingXMLCharacters];
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public enum FeatureFlag: Int, CaseIterable {
case .allowApplicationPasswords:
return false
case .selfHostedSiteUserManagement:
return false
return true
case .readerGutenbergCommentComposer:
return false
case .pluginManagementOverhaul:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ private struct Section {
tableView.register(JetpackBrandingMenuCardCell.self, forCellReuseIdentifier: CellIdentifiers.jetpackBrandingCard)
tableView.register(JetpackRemoteInstallTableViewCell.self, forCellReuseIdentifier: CellIdentifiers.jetpackInstall)
tableView.register(ExtensiveLoggingCell.self, forCellReuseIdentifier: CellIdentifiers.extensiveLogging)
tableView.register(XMLRPCDisabledCell.self, forCellReuseIdentifier: CellIdentifiers.xmlrpcDisabled)

tableView.delegate = self
tableView.dataSource = self
Expand Down Expand Up @@ -106,6 +107,10 @@ private struct Section {
newSections.append(Section(rows: [], category: .extensiveLogging))
}

if blog.isSelfHosted, viewController.showXMLRPCDisabled {
newSections.append(Section(rows: [], category: .xmlrpcDisabled))
}

if viewController.isDashboardEnabled() && isSplitViewDisplayed {
newSections.append(buildHomeSection())
}
Expand Down Expand Up @@ -243,7 +248,7 @@ extension BlogDetailsTableViewModel: UITableViewDataSource {
guard section < sections.count else { return 0 }

switch sections[section].category {
case .jetpackInstallCard, .migrationSuccess, .jetpackBrandingCard, .extensiveLogging:
case .jetpackInstallCard, .migrationSuccess, .jetpackBrandingCard, .extensiveLogging, .xmlrpcDisabled:
// The "card" sections do not set the `rows` property. It's hard-coded to show specific types of cards.
wpAssert(sections[section].rows.count == 0)
return 1
Expand All @@ -269,6 +274,8 @@ extension BlogDetailsTableViewModel: UITableViewDataSource {
cell = configureJetpackBrandingCell(tableView: tableView)
case .extensiveLogging:
cell = configureExtensiveLoggingCell(tableView: tableView)
case .xmlrpcDisabled:
cell = configureXMLRPCDisabledCell(tableView: tableView)
default:
if indexPath.row < section.rows.count {
let row = section.rows[indexPath.row]
Expand Down Expand Up @@ -496,6 +503,18 @@ private extension BlogDetailsTableViewModel {
cell.configure(with: viewController)
return cell
}

func configureXMLRPCDisabledCell(tableView: UITableView) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: CellIdentifiers.xmlrpcDisabled
) as? XMLRPCDisabledCell,
let viewController else {
return UITableViewCell()
}

cell.configure(with: viewController)
return cell
}
}

private extension BlogDetailsTableViewModel {
Expand Down Expand Up @@ -827,6 +846,7 @@ private enum SectionCategory {
case reminders
case domainCredit
case extensiveLogging
case xmlrpcDisabled
case home
case general
case jetpack
Expand Down Expand Up @@ -1493,4 +1513,5 @@ private enum CellIdentifiers {
static let jetpackBrandingCard = "BlogDetailsJetpackBrandingCardCellIdentifier"
static let jetpackInstall = "BlogDetailsJetpackInstallCardCellIdentifier"
static let extensiveLogging = "BlogDetailsExtensiveLoggingCellIdentifier"
static let xmlrpcDisabled = "BlogDetailsXMLRPCDisabledCellIdentifier"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import UIKit
import WordPressData
import WordPressKit
import WordPressShared
import WordPressUI
import Reachability
Expand All @@ -21,6 +22,7 @@ public class BlogDetailsViewController: UIViewController {

private lazy var blogService = BlogService(coreDataStack: ContextManager.shared)
private var hasLoggedDomainCreditPromptShownEvent = false
private(set) var showXMLRPCDisabled: Bool = false

init(blog: Blog) {
self.blog = blog
Expand Down Expand Up @@ -80,6 +82,7 @@ public class BlogDetailsViewController: UIViewController {
observeManagedObjectContextObjectsDidChangeNotification()
observeGravatarImageUpdate()
downloadGravatarImage()
checkXMLRPCStatus()

registerForTraitChanges([UITraitHorizontalSizeClass.self], action: #selector(handleTraitChanges))
}
Expand Down Expand Up @@ -192,6 +195,25 @@ public class BlogDetailsViewController: UIViewController {
blogService.refreshDomains(for: blog, success: nil, failure: nil)
}

private func checkXMLRPCStatus() {
guard blog.isSelfHosted, let xmlrpcApi = blog.xmlrpcApi,
let username = blog.username, let password = blog.password else {
showXMLRPCDisabled = false
return
}

Task { @MainActor in
let availability = await xmlrpcApi.isXMLRPCAvailable(username: username, password: password)
let wasDisabled = self.showXMLRPCDisabled
self.showXMLRPCDisabled = availability == .unavailable

if wasDisabled != self.showXMLRPCDisabled {
self.configureTableViewData()
self.reloadTableViewPreservingSelection()
}
}
}

public func showRemoveSiteAlert() {
let model = UIDevice.current.localizedModel
let message = String(format: NSLocalizedString(
Expand Down Expand Up @@ -243,6 +265,7 @@ public class BlogDetailsViewController: UIViewController {
@objc private func handleWillEnterForeground(_ notification: NSNotification) {
configureTableViewData()
reloadTableViewPreservingSelection()
checkXMLRPCStatus()
}

private func observeManagedObjectContextObjectsDidChangeNotification() {
Expand Down
Loading