diff --git a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift index b57c1fc73ae6..b6473577b0c2 100644 --- a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift +++ b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift @@ -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. @@ -437,3 +477,9 @@ private extension WordPressAPIError where EndpointError == WordPressOrgXMLRPCApi } } + +public enum XMLRPCAvailability: Equatable { + case available + case unavailable + case unknown +} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 6ddcf765a900..1271b59f7cce 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -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] diff --git a/Sources/WordPressData/Objective-C/Blog.m b/Sources/WordPressData/Objective-C/Blog.m index 2416fc07bb89..58db0042dc1b 100644 --- a/Sources/WordPressData/Objective-C/Blog.m +++ b/Sources/WordPressData/Objective-C/Blog.m @@ -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 diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index c7122c85750c..15cd91f5684a 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -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 diff --git a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift index 228c2f1b4965..23e651a3b78c 100644 --- a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift +++ b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift @@ -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 { let xmlrpc: URL = try await discoverXMLRPCEndpoint(site: credentials.siteUrl) let blogOptions: [AnyHashable: Any] do { @@ -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 } @@ -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 { + 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 + // 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 + 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 + } } diff --git a/WordPress/Classes/Services/Facades/BlogSyncFacade.m b/WordPress/Classes/Services/Facades/BlogSyncFacade.m index 9d1a5a29cd8c..03ec263beb73 100644 --- a/WordPress/Classes/Services/Facades/BlogSyncFacade.m +++ b/WordPress/Classes/Services/Facades/BlogSyncFacade.m @@ -44,6 +44,7 @@ - (void)syncBlogWithUsername:(NSString *)username blog.url = url; } if (blogName) { + [blog addSettingsIfNecessary]; blog.settings.name = [blogName stringByDecodingXMLCharacters]; } } diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 5e808b3478bf..d4f6c2e97977 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -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: diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift index 5457d4e4383c..004ce9e18aa2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -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 @@ -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()) } @@ -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 @@ -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] @@ -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 { @@ -827,6 +846,7 @@ private enum SectionCategory { case reminders case domainCredit case extensiveLogging + case xmlrpcDisabled case home case general case jetpack @@ -1493,4 +1513,5 @@ private enum CellIdentifiers { static let jetpackBrandingCard = "BlogDetailsJetpackBrandingCardCellIdentifier" static let jetpackInstall = "BlogDetailsJetpackInstallCardCellIdentifier" static let extensiveLogging = "BlogDetailsExtensiveLoggingCellIdentifier" + static let xmlrpcDisabled = "BlogDetailsXMLRPCDisabledCellIdentifier" } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift index 8d60c775c0e7..f2facff4a707 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift @@ -1,5 +1,6 @@ import UIKit import WordPressData +import WordPressKit import WordPressShared import WordPressUI import Reachability @@ -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 @@ -80,6 +82,7 @@ public class BlogDetailsViewController: UIViewController { observeManagedObjectContextObjectsDidChangeNotification() observeGravatarImageUpdate() downloadGravatarImage() + checkXMLRPCStatus() registerForTraitChanges([UITraitHorizontalSizeClass.self], action: #selector(handleTraitChanges)) } @@ -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( @@ -243,6 +265,7 @@ public class BlogDetailsViewController: UIViewController { @objc private func handleWillEnterForeground(_ notification: NSNotification) { configureTableViewData() reloadTableViewPreservingSelection() + checkXMLRPCStatus() } private func observeManagedObjectContextObjectsDidChangeNotification() { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/XMLRPCDisabledCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/XMLRPCDisabledCell.swift new file mode 100644 index 000000000000..9d7ffc29cdfd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/XMLRPCDisabledCell.swift @@ -0,0 +1,111 @@ +import UIKit +import DesignSystem +import SwiftUI +import WordPressUI + +class XMLRPCDisabledCell: UITableViewCell { + private weak var presenterViewController: UIViewController? + + private lazy var cardView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.masksToBounds = true + view.layer.cornerRadius = DesignConstants.radius(.large) + + let content = UIHostingView(view: CardContent()) + content.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(content) + view.pinSubviewToAllEdges(content) + + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showAlert))) + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + contentView.addSubview(cardView) + contentView.pinSubviewToAllEdges(cardView) + } + + func configure(with viewController: BlogDetailsViewController) { + presenterViewController = viewController + } + + @objc private func showAlert() { + guard let presenter = presenterViewController else { + return + } + + let alert = AlertView { + AlertHeaderView(title: Strings.alertTitle, description: Strings.alertMessage) + } content: { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundStyle(.orange) + } actions: { + AlertDismissButton() + } + + alert.present(in: presenter) + } +} + +private struct CardContent: View { + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(.orange) + .frame(width: 8, height: 8) + + VStack(alignment: .leading) { + Text(Strings.cardTitle) + .font(.subheadline) + .fontWeight(.medium) + Text(Strings.cardSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "info.circle.fill") + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } +} + +private enum Strings { + static let cardTitle = NSLocalizedString( + "blogDetails.xmlrpcDisabled.card.title", + value: "XML-RPC Disabled", + comment: "Title for the XML-RPC disabled card on blog details" + ) + static let cardSubtitle = NSLocalizedString( + "blogDetails.xmlrpcDisabled.card.subtitle", + value: "Some features may be limited", + comment: "Subtitle for the XML-RPC disabled card on blog details" + ) + + static let alertTitle = NSLocalizedString( + "blogDetails.xmlrpcDisabled.alert.title", + value: "XML-RPC Disabled", + comment: "Alert title for XML-RPC disabled" + ) + + static let alertMessage = NSLocalizedString( + "blogDetails.xmlrpcDisabled.alert.message", + value: "XML-RPC is currently unavailable on your site. The app is transitioning to WordPress REST API, but some features still require XML-RPC. You may experience limited functionality until this transition is complete.", + comment: "Alert message explaining that XML-RPC is disabled on the site and some features may be limited" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift index 952684cfd3a5..8fc54e70d206 100644 --- a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift @@ -452,8 +452,9 @@ class AbstractPostListViewController: UIViewController, return } - // Update in the background - syncItemsWithUserInteraction(false) + // If it's in the middle of pushing this controller, we'll treat it as "syncing with user interaction". + let userInteraction = isMovingToParent + syncItemsWithUserInteraction(userInteraction) } @objc func syncItemsWithUserInteraction(_ userInteraction: Bool) { @@ -608,6 +609,7 @@ class AbstractPostListViewController: UIViewController, hideRefreshingIndicator() dismissAllNetworkErrorNotices() + refreshResults() // If there is no internet connection, we'll show the specific error message defined in // `noConnectionMessage()` (overridden by subclasses). For everything else, we let