From dc43769a620919105598ed9d4c8bbd5abd45616b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 May 2025 15:34:09 +1200 Subject: [PATCH 01/14] Update wordpress-rs --- Modules/Package.resolved | 6 +++--- Modules/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 46c70b78950d..cbe798cb19c7 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2f50dc7601f992ac0e22d0e32a759ef5d14289c7cc5f8b887c46073f7d50391b", + "originHash" : "43fbce57dedae27ed83f0ebd59ff2f81e3f0deffd1a163762504793d8642905b", "pins" : [ { "identity" : "alamofire", @@ -380,8 +380,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20250520", - "revision" : "a1960ad79ecec20a8b18deda447f2f45630b14ce" + "branch" : "alpha-20250523", + "revision" : "ed3784376b8efd1eafcf85caa9055b1c3b1f80da" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 23c706dad519..ef20d1152e0f 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -54,7 +54,7 @@ let package = Package( ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. - .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250520"), + .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250523"), .package(url: "https://github.com/wordpress-mobile/GutenbergKit", revision: "fdfe788530bbff864ce7147b5a68608d7025e078"), .package( url: "https://github.com/Automattic/color-studio", From 63d6be4f4705c82f802369ae2f269f12f6931b67 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 May 2025 10:07:00 +1200 Subject: [PATCH 02/14] Add dot-org REST API via WP.com to `WordPressClient` --- .../WordPressData/Swift/Blog+SelfHosted.swift | 6 +- .../Classes/Networking/WordPressClient.swift | 75 +++++++++++++------ 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index 13e7b67a03cf..1434d0a84176 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -183,9 +183,9 @@ public enum WordPressSite { case selfHosted(blogId: TaggedManagedObjectID, apiRootURL: ParsedUrl, username: String, authToken: String) public init(blog: Blog) throws { - if let _ = blog.account { - // WP.com support is not ready yet. - throw NSError(domain: "WordPressAPI", code: 0) + if let account = blog.account, let siteId = blog.dotComID?.intValue { + let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username) + self = .dotCom(siteId: siteId, authToken: authToken) } else { let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString let apiRootURL = try ParsedUrl.parse(input: url) diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index 5f86f53fd818..6a0e0427145c 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -21,22 +21,28 @@ extension WordPressClient { // rather than using the shared one on disk). let session = URLSession(configuration: .ephemeral) + let notifier = AppNotifier() + let provider = WpAuthenticationProvider.dynamic( + dynamicAuthenticationProvider: AutoUpdateAuthenticationProvider(site: site, coreDataStack: ContextManager.shared) + ) + let apiRootURL: ParsedUrl + let resolver: ApiUrlResolver switch site { - case let .dotCom(siteId, authToken): - let apiRootURL = try! ParsedUrl.parse(input: "https://public-api.wordpress.com/wpcom/v2/site/\(siteId)") - let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authentication: .bearer(token: authToken)) - self.init(api: api, rootUrl: apiRootURL) - case let .selfHosted(blogId, apiRootURL, username, authToken): - let provider = AutoUpdateAuthenticationProvider( - authentication: .init(username: username, password: authToken), - blogId: blogId, - coreDataStack: ContextManager.shared - ) - let notifier = AppNotifier() - let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authenticationProvider: .dynamic(dynamicAuthenticationProvider: provider), appNotifier: notifier) - notifier.api = api - self.init(api: api, rootUrl: apiRootURL) + case let .dotCom(siteId, _): + apiRootURL = try! ParsedUrl.parse(input: "https://public-api.wordpress.com/wp/v2/site/\(siteId)") + resolver = WpComDotOrgApiUrlResolver(siteUrl: "\(siteId)") + case let .selfHosted(_, url, _, _): + apiRootURL = url + resolver = WpOrgSiteApiUrlResolver(apiRootUrl: url) } + let api = WordPressAPI( + urlSession: session, + apiUrlResolver: resolver, + authenticationProvider: provider, + appNotifier: notifier + ) + notifier.api = api + self.init(api: api, rootUrl: apiRootURL) } func installJetpack() async throws -> PluginWithEditContext { @@ -57,23 +63,44 @@ extension PluginWpOrgDirectorySlug: @retroactive ExpressibleByStringLiteral { private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDynamicAuthenticationProvider { private let lock = NSLock() + private let site: WordPressSite + private let coreDataStack: CoreDataStack private var authentication: WpAuthentication private var cancellable: AnyCancellable? - init(authentication: WpAuthentication, blogId: TaggedManagedObjectID, coreDataStack: CoreDataStack) { - self.authentication = authentication + init(site: WordPressSite, coreDataStack: CoreDataStack) { + 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) + } + self.cancellable = NotificationCenter.default.publisher(for: SelfHostedSiteAuthenticator.applicationPasswordUpdated).sink { [weak self] _ in - guard let self else { return } + self?.update() + } + } - self.lock.lock() - defer { - self.lock.unlock() - } + func update() { + self.lock.lock() + defer { + self.lock.unlock() + } - self.authentication = coreDataStack.performQuery { context in + self.authentication = coreDataStack.performQuery { [site] context in + switch site { + case let .dotCom(siteId, _): + guard let blog = try? Blog.lookup(withID: siteId, in: context), + let token = blog.authToken else { + return WpAuthentication.none + } + return WpAuthentication.bearer(token: token) + case let .selfHosted(blogId, _, _, _): guard let blog = try? context.existingObject(with: blogId), - let username = try? blog.getUsername(), - let password = try? blog.getApplicationToken() + let username = try? blog.getUsername(), + let password = try? blog.getApplicationToken() else { return WpAuthentication.none } From 341a6492e03a0a7e6ebd58496846dcc559a19097 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 May 2025 10:13:45 +1200 Subject: [PATCH 03/14] Add blog id to the `reauthentication` context So that the new application password can be added to the blog --- .../WordPressData/Swift/Blog+SelfHosted.swift | 10 +++++++-- ...licationPasswordReAuthenticationView.swift | 2 +- .../ApplicationPasswordRequiredView.swift | 2 +- .../Login/SelfHostedSiteAuthenticator.swift | 21 ++++++++++++++++--- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index 1434d0a84176..4558c2bb63f4 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -20,12 +20,18 @@ public extension Blog { with details: WpApiApplicationPasswordDetails, restApiRootURL: URL, xmlrpcEndpointURL: URL, + blogID: TaggedManagedObjectID?, in contextManager: ContextManager, using keychainImplementation: KeychainAccessible = KeychainUtils() ) async throws -> TaggedManagedObjectID { try await contextManager.performAndSave { context in - let blog = 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 blog.restApiRootURL = restApiRootURL.absoluteString diff --git a/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift b/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift index 847892f50455..7dba1ba920f3 100644 --- a/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift +++ b/WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift @@ -37,7 +37,7 @@ struct ApplicationPasswordReAuthenticationView: View { .signIn( site: blog.getUrl().absoluteString, from: presenter, - context: .reauthentication(username: blog.getUsername()) + context: .reauthentication(TaggedManagedObjectID(blog), username: blog.getUsername()) ) // Automatically dismiss this view upon a successful re-authentication. diff --git a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift index 22bade4108c6..2ec6666953c3 100644 --- a/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift +++ b/WordPress/Classes/Login/ApplicationPasswordRequiredView.swift @@ -45,7 +45,7 @@ struct ApplicationPasswordRequiredView: View { do { // Get an application password for the given site. let authenticator = SelfHostedSiteAuthenticator() - let blogID = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(username: blog.username)) + let blogID = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(TaggedManagedObjectID(blog), username: blog.username)) // Modify the `site` variable to display the intended feature. let blog = try ContextManager.shared.mainContext.existingObject(with: blogID) diff --git a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift index f63eea5f7fd2..e4a4d8550771 100644 --- a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift +++ b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift @@ -16,7 +16,16 @@ struct SelfHostedSiteAuthenticator { // Sign in to a self-hosted site. Using this context results in automatically reloading the app to display the site dashboard. case `default` // Sign in to a site that's alredy added to the app. This is typically used when the app needs to get a new application password. - case reauthentication(username: String?) + case reauthentication(TaggedManagedObjectID, username: String?) + + var blogID: TaggedManagedObjectID? { + switch self { + case .default: + return nil + case let .reauthentication(blogID, _): + return blogID + } + } } private static let callbackURL = URL(string: "x-wordpress-app://login-callback")! @@ -150,7 +159,7 @@ struct SelfHostedSiteAuthenticator { SVProgressHUD.dismiss() } - if case let .reauthentication(username) = context, let username, username != credentials.userLogin { + if case let .reauthentication(_, username) = context, let username, username != credentials.userLogin { throw .mismatchedUser(expectedUsername: username) } @@ -165,7 +174,13 @@ struct SelfHostedSiteAuthenticator { // Only store the new site after credentials are validated. let blog: TaggedManagedObjectID do { - blog = try await Blog.createRestApiBlog(with: credentials, restApiRootURL: apiRootURL, xmlrpcEndpointURL: xmlrpc, in: ContextManager.shared) + blog = try await Blog.createRestApiBlog( + with: credentials, + restApiRootURL: apiRootURL, + xmlrpcEndpointURL: xmlrpc, + blogID: context.blogID, + in: ContextManager.shared + ) } catch { throw .savingSiteFailure } From ccc796dbfdd67aa1717868f2d5da1a245f89798d Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 May 2025 10:14:43 +1200 Subject: [PATCH 04/14] Add API to accept an auto-discovery result --- .../Login/SelfHostedSiteAuthenticator.swift | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift index e4a4d8550771..a7a9bb63d3db 100644 --- a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift +++ b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift @@ -87,30 +87,32 @@ struct SelfHostedSiteAuthenticator { @MainActor func signIn(site: String, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID { + let details: AutoDiscoveryAttemptSuccess do { - let result = try await _signIn(site: site, from: viewController, context: context) - trackSuccess(url: site) - return result + details = try await internalClient.details(ofSite: site) } catch { - trackTypedError(error, url: site) - throw error + trackTypedError(.authentication(error), url: site) + throw .authentication(error) } + + return try await signIn(details: details, from: viewController, context: context) } @MainActor - private func _signIn(site: String, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID { + func signIn(details: AutoDiscoveryAttemptSuccess, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID { do { - let (apiRootURL, credentials) = try await authenticate(site: site, from: viewController) - return try await handle(credentials: credentials, apiRootURL: apiRootURL, context: context) - } catch let error as SignInError { - throw error + let (apiRootURL, credentials) = try await authenticate(details: details, from: viewController) + let result = try await handle(credentials: credentials, apiRootURL: apiRootURL, context: context) + trackSuccess(url: details.parsedSiteUrl.url()) + return result } catch { - throw .authentication(error) + trackTypedError(error, url: details.parsedSiteUrl.url()) + throw error } } @MainActor - private func authenticate(site: String, from viewController: UIViewController) async throws -> (apiRootURL: URL, credentials: WpApiApplicationPasswordDetails) { + private func authenticate(details: AutoDiscoveryAttemptSuccess, from viewController: UIViewController) async throws(SignInError) -> (apiRootURL: URL, credentials: WpApiApplicationPasswordDetails) { let appId: WpUuid let appName: String @@ -126,10 +128,13 @@ struct SelfHostedSiteAuthenticator { let timestamp = ISO8601DateFormatter.string(from: .now, timeZone: .current, formatOptions: .withInternetDateTime) let appNameValue = "\(appName) - \(deviceName) (\(timestamp))" - let details = try await internalClient.details(ofSite: site) - let loginURL = try details.loginURL(for: .init(id: appId, name: appNameValue, callbackUrl: SelfHostedSiteAuthenticator.callbackURL.absoluteString)) - let callback = try await authorize(url: loginURL, callbackURL: SelfHostedSiteAuthenticator.callbackURL, from: viewController) - return (details.apiRootUrl.asURL(), try internalClient.credentials(from: callback)) + do { + let loginURL = details.loginURL(for: .init(id: appId, name: appNameValue, callbackUrl: SelfHostedSiteAuthenticator.callbackURL.absoluteString)) + let callback = try await authorize(url: loginURL, callbackURL: SelfHostedSiteAuthenticator.callbackURL, from: viewController) + return (details.apiRootUrl.asURL(), try internalClient.credentials(from: callback)) + } catch { + throw .authentication(error) + } } @MainActor From c8c5deb81301fe5253256fc2dba94a413c8eeb32 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 May 2025 10:54:59 +1200 Subject: [PATCH 05/14] Ask users to grant the app an application password Only available when the "Application Passwords for self-hosted sites" feature flag is on. The feature flag is available as one of the "Experimental Features". --- .../WordPressData/Swift/Blog+SelfHosted.swift | 19 +++++ ...pplicationPasswordAuthenticationCard.swift | 48 +++++++++++ .../BlogDetailsViewController+Swift.swift | 83 +++++++++++++++++++ .../Blog Details/BlogDetailsViewController.h | 8 +- .../Blog Details/BlogDetailsViewController.m | 20 +++++ 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index 4558c2bb63f4..13aa310b6f17 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -198,4 +198,23 @@ public enum WordPressSite { self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken()) } } + + public static func throughDotCom(blog: Blog) -> Self? { + guard + let account = blog.account, + let siteId = blog.dotComID?.intValue, + let authToken = try? account.authToken ?? WPAccount.token(forUsername: account.username) + else { return nil } + + return .dotCom(siteId: siteId, authToken: authToken) + } + + 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) + } + } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift new file mode 100644 index 000000000000..d6bda0117e9e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift @@ -0,0 +1,48 @@ +import Foundation +import WordPressUI +import SwiftUI + +class ApplicationPasswordAuthenticationCardCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + let view = UIHostingView(view: ApplicationPasswordAuthenticationCard()) + self.contentView.addSubview(view) + view.pinEdges() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +struct ApplicationPasswordAuthenticationCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Image(systemName: "lock.circle.fill") + .foregroundColor(.red) + + Text(Strings.newTitle) + .foregroundColor(.primary) + } + .font(.headline) + + Text(Strings.description) + .font(.callout) + .foregroundColor(.primary) + + Text(Strings.authorize) + .font(.callout.bold()) + .foregroundStyle(Color.accentColor) + } + .padding() + } +} + + +private enum Strings { + static let newTitle = NSLocalizedString("application.password.new.title", value: "New: Application Passwords", comment: "Title for the new application passwords feature") + static let description = NSLocalizedString("application.password.description", value: "You can now grant the app permission to use application passwords for quick and secure access to your site content.", comment: "Description for the application passwords feature") + static let authorize = NSLocalizedString("application.password.authorize", value: "Authorize", comment: "Button label to authorize application passwords") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 85e96caeb018..93c0a121b08f 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -1,5 +1,7 @@ import Foundation import WordPressShared +import WordPressAPI +import WordPressCore // MARK: - BlogDetailsViewController (Misc) @@ -345,6 +347,74 @@ extension BlogDetailsViewController { let controller = SiteMonitoringViewController(blog: blog, selectedTab: selectedTab) presentationDelegate?.presentBlogDetailsViewController(controller) } + + @objc public func checkApplicationPasswordEligibility() { + guard FeatureFlag.authenticateUsingApplicationPassword.enabled else { return } + + // We have already got an application token for this site, no need to ask for another one. + guard (try? blog.getApplicationToken()) == nil, let url = blog.url else { return } + + Task { @MainActor in + // If the auto disocvery process is successful, we consider the site supports application password. + let siteDetails = try await WordPressLoginClient(urlSession: URLSession(configuration: .ephemeral)).details(ofSite: url) + + // Since the site is already added to the app, we need to find the current user/account in the site itself, + // not the one in WP.com. + // + // The "site username" could be + // - When the site is added via the signed in WP.com account, the username of the self-hosted user that + // connected the site to the signed in WP.com account. Or, + // - When the site is added as a self-hosted site, the username of the logged in site: `blog.username`. + let siteUsername: String + if let site = WordPressSite.throughDotCom(blog: blog) { + // For sites that are added to the app via a WP.com account, we can find out the user using the core + // REST API via WP.com: https://public-api.wordpress.com/wp/v2/sites/$site_id/users/me + let client = WordPressClient(site: site) + siteUsername = try await client.api.users.retrieveMeWithEditContext().data.username + } else if let username = blog.username { + // For sites that are added as self-hosted sites, the `username` is what we need. + siteUsername = username + } else { + return + } + + self.applicationPasswordAuthenticationInfo = .init(siteAddress: url, siteDetails: siteDetails, siteUsername: siteUsername) + } + } + + @objc public func applicationPasswordAuthenticationSectionViewModel() -> BlogDetailsSection { + let row = BlogDetailsRow() + row.callback = { [weak self] in + Task { + await self?.startApplicationPasswordAuthenticationFlow() + } + } + return BlogDetailsSection( + title: nil, + rows: [row], + footerTitle: nil, + category: .applicationPasswordAuthentication + ) + } + + @MainActor + private func startApplicationPasswordAuthenticationFlow() async { + guard let info = self.applicationPasswordAuthenticationInfo else { + return + } + + let authenticator = SelfHostedSiteAuthenticator() + do { + let _ = try await authenticator.signIn( + details: info.siteDetails, + from: self, + context: .reauthentication(TaggedManagedObjectID(blog), username: info.siteUsername) + ) + self.applicationPasswordAuthenticationInfo = nil + } catch { + DDLogError("Application password authentication failed: \(error)") + } + } } // MARK: - BlogDetailsViewController (Tracking) @@ -391,3 +461,16 @@ private enum Strings { static let users = NSLocalizedString("mySite.menu.users", value: "Users", comment: "Title for the menu item") static let subscribers = NSLocalizedString("mySite.menu.subscribers", value: "Subscribers", comment: "Title for the menu item") } + +// Necessary data that's required to get an application application from a given site. +@objc public class ApplicationPasswordAuthenticationInfo: NSObject { + public let siteAddress: String + public let siteDetails: AutoDiscoveryAttemptSuccess + public let siteUsername: String + + public init(siteAddress: String, siteDetails: AutoDiscoveryAttemptSuccess, siteUsername: String) { + self.siteAddress = siteAddress + self.siteDetails = siteDetails + self.siteUsername = siteUsername + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h index 8217b864e03c..aaef1239df64 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h @@ -5,6 +5,7 @@ @class CreateButtonCoordinator; @class IntrinsicTableView; @class MeViewController; +@class ApplicationPasswordAuthenticationInfo; @protocol BlogDetailHeader; typedef NS_ENUM(NSUInteger, BlogDetailsSectionCategory) { @@ -23,7 +24,8 @@ typedef NS_ENUM(NSUInteger, BlogDetailsSectionCategory) { BlogDetailsSectionCategorySotW2023Card, BlogDetailsSectionCategoryContent, BlogDetailsSectionCategoryTraffic, - BlogDetailsSectionCategoryMaintenance + BlogDetailsSectionCategoryMaintenance, + BlogDetailsSectionCategoryApplicationPasswordAuthentication }; typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { @@ -127,6 +129,10 @@ typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { /// A new display mode for the displaying it as part of the site menu. @property (nonatomic) BOOL isSidebarModeEnabled; +/// When this property value is not nil, the view controller shows an "Application Passwords" to allow users to +/// grant the app an application password. +@property (nonatomic) ApplicationPasswordAuthenticationInfo *applicationPasswordAuthenticationInfo; + @property (nonatomic, weak) UIViewController *presentedSiteSettingsViewController; - (id _Nonnull)init; diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m index be6741b5c272..b72d6de6d315 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m @@ -28,6 +28,7 @@ static NSString *const BlogDetailsJetpackBrandingCardCellIdentifier = @"BlogDetailsJetpackBrandingCardCellIdentifier"; static NSString *const BlogDetailsJetpackInstallCardCellIdentifier = @"BlogDetailsJetpackInstallCardCellIdentifier"; static NSString *const BlogDetailsSotWCardCellIdentifier = @"BlogDetailsSotWCardCellIdentifier"; +static NSString *const BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier = @"BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier"; CGFloat const BlogDetailGridiconSize = 24.0; CGFloat const BlogDetailGridiconAccessorySize = 17.0; @@ -307,6 +308,7 @@ - (void)viewDidLoad [self.tableView registerClass:[JetpackBrandingMenuCardCell class] forCellReuseIdentifier:BlogDetailsJetpackBrandingCardCellIdentifier]; [self.tableView registerClass:[JetpackRemoteInstallTableViewCell class] forCellReuseIdentifier:BlogDetailsJetpackInstallCardCellIdentifier]; [self.tableView registerClass:[SotWTableViewCell class] forCellReuseIdentifier:BlogDetailsSotWCardCellIdentifier]; + [self.tableView registerClass:[ApplicationPasswordAuthenticationCardCell class] forCellReuseIdentifier:BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier]; self.tableView.cellLayoutMarginsFollowReadableWidth = YES; @@ -345,6 +347,7 @@ - (void)viewWillAppear:(BOOL)animated [self reloadTableViewPreservingSelection]; [self preloadBlogData]; + [self checkApplicationPasswordEligibility]; } - (void)viewDidAppear:(BOOL)animated @@ -987,6 +990,10 @@ - (void)configureTableViewData [marr addNullableObject:[self homeSectionViewModel]]; } + if (self.applicationPasswordAuthenticationInfo != nil) { + [marr addNullableObject:[self applicationPasswordAuthenticationSectionViewModel]]; + } + if (ObjCBridge.isWordPress) { if ([self shouldAddJetpackSection]) { [marr addNullableObject:[self jetpackSectionViewModel]]; @@ -1474,6 +1481,15 @@ - (void)showInitialDetailsForBlog } } +- (void)setApplicationPasswordAuthenticationInfo:(ApplicationPasswordAuthenticationInfo *)applicationPasswordAuthenticationInfo { + if (_applicationPasswordAuthenticationInfo != applicationPasswordAuthenticationInfo) { + _applicationPasswordAuthenticationInfo = applicationPasswordAuthenticationInfo; + + [self configureTableViewData]; + [self reloadTableViewPreservingSelection]; + } +} + #pragma mark - Table view data source - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView @@ -1537,6 +1553,10 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N return cell; } + if (section.category == BlogDetailsSectionCategoryApplicationPasswordAuthentication) { + return [tableView dequeueReusableCellWithIdentifier:BlogDetailsApplicationPasswordAuthenticationCardCellIdentifier]; + } + BlogDetailsRow *row = [section.rows objectAtIndex:indexPath.row]; UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.identifier]; From 6e34f2bea4c41d3dc2609fdffcd650b8f4fdb9e1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 May 2025 11:01:14 +1200 Subject: [PATCH 06/14] Prefer to directly access site content via dot-org REST API --- Sources/WordPressData/Swift/Blog+SelfHosted.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index 13aa310b6f17..b193c61a7481 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -189,10 +189,19 @@ public enum WordPressSite { case selfHosted(blogId: TaggedManagedObjectID, apiRootURL: ParsedUrl, username: String, authToken: String) public init(blog: Blog) throws { - if let account = blog.account, let siteId = blog.dotComID?.intValue { + // Directly access the site content when available. + 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), 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(siteId: siteId, authToken: authToken) } 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.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString let apiRootURL = try ParsedUrl.parse(input: url) self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken()) From cdd07de8a80bf15fd35d4543825a358071677625 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 23 May 2025 21:25:59 +1200 Subject: [PATCH 07/14] Fix a unit test compiling issue --- Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift b/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift index d0eb511ddaec..103d065e6ea1 100644 --- a/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift +++ b/Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift @@ -19,6 +19,7 @@ final class Blog_RestAPITests: CoreDataTestCase { with: loginDetails, restApiRootURL: URL(string: "https://example.com/wp-json")!, xmlrpcEndpointURL: URL(string: "https://example.com/dir/xmlrpc.php")!, + blogID: nil, in: contextManager, using: testKeychain ) From fbf467399ca936cc638a79ae94f662cdde7c3b65 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 26 May 2025 14:11:17 +1200 Subject: [PATCH 08/14] Fix a swiftlint issue --- .../Blog Details/ApplicationPasswordAuthenticationCard.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift index d6bda0117e9e..0e96677dfcfc 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift @@ -40,7 +40,6 @@ struct ApplicationPasswordAuthenticationCard: View { } } - private enum Strings { static let newTitle = NSLocalizedString("application.password.new.title", value: "New: Application Passwords", comment: "Title for the new application passwords feature") static let description = NSLocalizedString("application.password.description", value: "You can now grant the app permission to use application passwords for quick and secure access to your site content.", comment: "Description for the application passwords feature") From 67e236efe1709c7a62d65aac105150864c17174c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 26 May 2025 14:31:09 +1200 Subject: [PATCH 09/14] Fix a swiftlint issue --- Sources/WordPressData/Swift/Blog+SelfHosted.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/WordPressData/Swift/Blog+SelfHosted.swift b/Sources/WordPressData/Swift/Blog+SelfHosted.swift index b193c61a7481..060b8c98c651 100644 --- a/Sources/WordPressData/Swift/Blog+SelfHosted.swift +++ b/Sources/WordPressData/Swift/Blog+SelfHosted.swift @@ -222,7 +222,7 @@ public enum WordPressSite { switch self { case let .dotCom(siteId, _): return try Blog.lookup(withID: siteId, in: context) - case let .selfHosted(blogId, _, _ , _): + case let .selfHosted(blogId, _, _, _): return try context.existingObject(with: blogId) } } From 068ac4ccec461cfbebd0509d336afc344e377d59 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 26 May 2025 14:51:14 +1200 Subject: [PATCH 10/14] Fix a compiling issue in the Reader app --- .../Blog Details/ApplicationPasswordAuthenticationCard.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift index 0e96677dfcfc..77b9df279d9e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/ApplicationPasswordAuthenticationCard.swift @@ -2,7 +2,7 @@ import Foundation import WordPressUI import SwiftUI -class ApplicationPasswordAuthenticationCardCell: UITableViewCell { +public class ApplicationPasswordAuthenticationCardCell: UITableViewCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) From b23d2a486b81e18036256d0d4f8f9a85431aa892 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 29 May 2025 10:33:41 +1200 Subject: [PATCH 11/14] Prefer to use REST API to manage comments --- .../CommentService+MorderationTests.swift | 129 ++++++++++++++++-- .../CommentServiceRemoteFactory.swift | 8 +- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift b/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift index 9fe4274bd4a8..945ba09cf41b 100644 --- a/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift +++ b/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift @@ -37,14 +37,33 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, + "parent": 0, + "author": 0, + "author_name": "A WordPress Commenter", + "author_email": "hello@example.com", + "author_url": "https://wordpress.org/", + "author_ip": "", + "author_user_agent": "", + "date": "2025-05-20T23:38:46", + "date_gmt": "2025-05-20T23:38:46", + "content": [ + "rendered": "

test comment

\n", + "raw": "test comment" + ], + "link": "https://example.com/2025/05/20/hello-world/#comment-1", "status": "approved", "type": "comment", - "content": "

test comment

\n", + "author_avatar_urls": [ + "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" + ], + "meta": [] ] as [String: Any], statusCode: 200, headers: nil @@ -69,20 +88,38 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, - "status": "pending", + "parent": 0, + "author": 0, + "author_name": "A WordPress Commenter", + "author_email": "hello@example.com", + "author_url": "https://wordpress.org/", + "author_ip": "", + "author_user_agent": "", + "date": "2025-05-20T23:38:46", + "date_gmt": "2025-05-20T23:38:46", + "content": [ + "rendered": "

test comment

\n", + "raw": "test comment" + ], + "link": "https://example.com/2025/05/20/hello-world/#comment-1", + "status": "hold", "type": "comment", - "content": "

test comment

\n", + "author_avatar_urls": [ + "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" + ], + "meta": [] ] as [String: Any], statusCode: 200, headers: nil ) } - // Call the moderation function and wait for it to complete waitUntil { done in commentService.unapproveComment(self.comment) { @@ -101,20 +138,38 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, + "parent": 0, + "author": 0, + "author_name": "A WordPress Commenter", + "author_email": "hello@example.com", + "author_url": "https://wordpress.org/", + "author_ip": "", + "author_user_agent": "", + "date": "2025-05-20T23:38:46", + "date_gmt": "2025-05-20T23:38:46", + "content": [ + "rendered": "

test comment

\n", + "raw": "test comment" + ], + "link": "https://example.com/2025/05/20/hello-world/#comment-1", "status": "spam", "type": "comment", - "content": "

test comment

\n", + "author_avatar_urls": [ + "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" + ], + "meta": [] ] as [String: Any], statusCode: 200, headers: nil ) } - // Call the moderation function and wait for it to complete waitUntil { done in commentService.spamComment(self.comment) { @@ -133,20 +188,38 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, + "parent": 0, + "author": 0, + "author_name": "A WordPress Commenter", + "author_email": "hello@example.com", + "author_url": "https://wordpress.org/", + "author_ip": "", + "author_user_agent": "", + "date": "2025-05-20T23:38:46", + "date_gmt": "2025-05-20T23:38:46", + "content": [ + "rendered": "

test comment

\n", + "raw": "test comment" + ], + "link": "https://example.com/2025/05/20/hello-world/#comment-1", "status": "trash", "type": "comment", - "content": "

test comment

\n", + "author_avatar_urls": [ + "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" + ], + "meta": [] ] as [String: Any], statusCode: 200, headers: nil ) } - // Call the moderation function and wait for it to complete waitUntil { done in commentService.trashComment(self.comment) { @@ -165,9 +238,37 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3/delete")) { _ in + stub(condition: isMethodDELETE() && isPath("/wp/v2/sites/1/comments/3")) { _ in HTTPStubsResponse( - jsonObject: [String: Any](), + jsonObject: [ + "deleted": true, + "previous": [ + "id": 3, + "post": 2, + "parent": 0, + "author": 0, + "author_name": "A WordPress Commenter", + "author_email": "hello@example.com", + "author_url": "https://wordpress.org/", + "author_ip": "", + "author_user_agent": "", + "date": "2025-05-20T23:38:46", + "date_gmt": "2025-05-20T23:38:46", + "content": [ + "rendered": "

test comment

\n", + "raw": "test comment" + ], + "link": "https://example.com/2025/05/20/hello-world/#comment-1", + "status": "approved", + "type": "comment", + "author_avatar_urls": [ + "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" + ], + "meta": [] + ] + ] as [String: Any], statusCode: 200, headers: nil ) diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift index db3f6a0e0a59..f9747fa2eae2 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -9,16 +9,16 @@ import WordPressKit /// - Parameter blog: A valid Blog object /// - Returns: A CommentServiceRemote instance @objc public func remote(blog: Blog) -> CommentServiceRemote? { + if let site = try? WordPressSite(blog: blog) { + return CommentServiceRemoteCoreRESTAPI(client: .init(site: site)) + } + if blog.supports(.wpComRESTAPI), let api = blog.wordPressComRestApi, let dotComID = blog.dotComID { return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) } - if let site = try? WordPressSite(blog: blog) { - return CommentServiceRemoteCoreRESTAPI(client: .init(site: site)) - } - if let api = blog.xmlrpcApi, let username = blog.username, let password = blog.password { From 88e42690fcae07075079ccb5cb952c43e2867df4 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 29 May 2025 10:42:27 +1200 Subject: [PATCH 12/14] Add a comment about why choosing WP.com API instead of REST API --- WordPress/Classes/Services/MediaRepository.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/WordPress/Classes/Services/MediaRepository.swift b/WordPress/Classes/Services/MediaRepository.swift index 68cd314089f7..109bef7983b6 100644 --- a/WordPress/Classes/Services/MediaRepository.swift +++ b/WordPress/Classes/Services/MediaRepository.swift @@ -94,6 +94,9 @@ private extension MediaRepository { return MediaServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) } + // We use WordPress.com API for media management instead of WordPress core REST API to ensure + // compatibility with WordPress.com-specific features such as video upload restrictions + // and storage limits based on the site's plan. if let site = try? WordPressSite(blog: blog) { return MediaServiceRemoteCoreREST(client: .init(site: site)) } From 0565abf631287baed3c1d76ae5f675e5b4f3d980 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 30 May 2025 11:43:54 +1200 Subject: [PATCH 13/14] Revert "Prefer to use REST API to manage comments" This reverts commit b23d2a486b81e18036256d0d4f8f9a85431aa892. --- .../CommentService+MorderationTests.swift | 129 ++---------------- .../CommentServiceRemoteFactory.swift | 8 +- 2 files changed, 18 insertions(+), 119 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift b/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift index 945ba09cf41b..9fe4274bd4a8 100644 --- a/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift +++ b/Tests/KeystoneTests/Tests/Services/CommentService+MorderationTests.swift @@ -37,33 +37,14 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, - "parent": 0, - "author": 0, - "author_name": "A WordPress Commenter", - "author_email": "hello@example.com", - "author_url": "https://wordpress.org/", - "author_ip": "", - "author_user_agent": "", - "date": "2025-05-20T23:38:46", - "date_gmt": "2025-05-20T23:38:46", - "content": [ - "rendered": "

test comment

\n", - "raw": "test comment" - ], - "link": "https://example.com/2025/05/20/hello-world/#comment-1", "status": "approved", "type": "comment", - "author_avatar_urls": [ - "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", - "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", - "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" - ], - "meta": [] + "content": "

test comment

\n", ] as [String: Any], statusCode: 200, headers: nil @@ -88,38 +69,20 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, - "parent": 0, - "author": 0, - "author_name": "A WordPress Commenter", - "author_email": "hello@example.com", - "author_url": "https://wordpress.org/", - "author_ip": "", - "author_user_agent": "", - "date": "2025-05-20T23:38:46", - "date_gmt": "2025-05-20T23:38:46", - "content": [ - "rendered": "

test comment

\n", - "raw": "test comment" - ], - "link": "https://example.com/2025/05/20/hello-world/#comment-1", - "status": "hold", + "status": "pending", "type": "comment", - "author_avatar_urls": [ - "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", - "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", - "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" - ], - "meta": [] + "content": "

test comment

\n", ] as [String: Any], statusCode: 200, headers: nil ) } + // Call the moderation function and wait for it to complete waitUntil { done in commentService.unapproveComment(self.comment) { @@ -138,38 +101,20 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, - "parent": 0, - "author": 0, - "author_name": "A WordPress Commenter", - "author_email": "hello@example.com", - "author_url": "https://wordpress.org/", - "author_ip": "", - "author_user_agent": "", - "date": "2025-05-20T23:38:46", - "date_gmt": "2025-05-20T23:38:46", - "content": [ - "rendered": "

test comment

\n", - "raw": "test comment" - ], - "link": "https://example.com/2025/05/20/hello-world/#comment-1", "status": "spam", "type": "comment", - "author_avatar_urls": [ - "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", - "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", - "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" - ], - "meta": [] + "content": "

test comment

\n", ] as [String: Any], statusCode: 200, headers: nil ) } + // Call the moderation function and wait for it to complete waitUntil { done in commentService.spamComment(self.comment) { @@ -188,38 +133,20 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodPOST() && isPath("/wp/v2/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3")) { _ in HTTPStubsResponse( jsonObject: [ "id": 3, "post": 2, - "parent": 0, - "author": 0, - "author_name": "A WordPress Commenter", - "author_email": "hello@example.com", - "author_url": "https://wordpress.org/", - "author_ip": "", - "author_user_agent": "", - "date": "2025-05-20T23:38:46", - "date_gmt": "2025-05-20T23:38:46", - "content": [ - "rendered": "

test comment

\n", - "raw": "test comment" - ], - "link": "https://example.com/2025/05/20/hello-world/#comment-1", "status": "trash", "type": "comment", - "author_avatar_urls": [ - "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", - "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", - "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" - ], - "meta": [] + "content": "

test comment

\n", ] as [String: Any], statusCode: 200, headers: nil ) } + // Call the moderation function and wait for it to complete waitUntil { done in commentService.trashComment(self.comment) { @@ -238,37 +165,9 @@ final class CommentService_MorderationTests: CoreDataTestCase { let commentService = CommentService(coreDataStack: contextManager) // Add a successful HTTP API call stub - stub(condition: isMethodDELETE() && isPath("/wp/v2/sites/1/comments/3")) { _ in + stub(condition: isMethodPOST() && isPath("/rest/v1.1/sites/1/comments/3/delete")) { _ in HTTPStubsResponse( - jsonObject: [ - "deleted": true, - "previous": [ - "id": 3, - "post": 2, - "parent": 0, - "author": 0, - "author_name": "A WordPress Commenter", - "author_email": "hello@example.com", - "author_url": "https://wordpress.org/", - "author_ip": "", - "author_user_agent": "", - "date": "2025-05-20T23:38:46", - "date_gmt": "2025-05-20T23:38:46", - "content": [ - "rendered": "

test comment

\n", - "raw": "test comment" - ], - "link": "https://example.com/2025/05/20/hello-world/#comment-1", - "status": "approved", - "type": "comment", - "author_avatar_urls": [ - "24": "https://secure.gravatar.com/avatar/123?s=24&d=mm&r=g", - "48": "https://secure.gravatar.com/avatar/123?s=48&d=mm&r=g", - "96": "https://secure.gravatar.com/avatar/123?s=96&d=mm&r=g" - ], - "meta": [] - ] - ] as [String: Any], + jsonObject: [String: Any](), statusCode: 200, headers: nil ) diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift index f9747fa2eae2..db3f6a0e0a59 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -9,16 +9,16 @@ import WordPressKit /// - Parameter blog: A valid Blog object /// - Returns: A CommentServiceRemote instance @objc public func remote(blog: Blog) -> CommentServiceRemote? { - if let site = try? WordPressSite(blog: blog) { - return CommentServiceRemoteCoreRESTAPI(client: .init(site: site)) - } - if blog.supports(.wpComRESTAPI), let api = blog.wordPressComRestApi, let dotComID = blog.dotComID { return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) } + if let site = try? WordPressSite(blog: blog) { + return CommentServiceRemoteCoreRESTAPI(client: .init(site: site)) + } + if let api = blog.xmlrpcApi, let username = blog.username, let password = blog.password { From bcad0398cd5495047fc104e77943c21b0ec31cad Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 30 May 2025 11:45:41 +1200 Subject: [PATCH 14/14] Add a comment about use WP.com API for comments --- WordPress/Classes/Services/CommentServiceRemoteFactory.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift index db3f6a0e0a59..c636d735e523 100644 --- a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -15,6 +15,7 @@ import WordPressKit return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) } + // The REST API does not have information about comment "likes". We'll continue to use WordPress.com API for now. if let site = try? WordPressSite(blog: blog) { return CommentServiceRemoteCoreRESTAPI(client: .init(site: site)) }