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
6 changes: 3 additions & 3 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 @@ -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",
Expand Down
44 changes: 39 additions & 5 deletions Sources/WordPressData/Swift/Blog+SelfHosted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@ public extension Blog {
with details: WpApiApplicationPasswordDetails,
restApiRootURL: URL,
xmlrpcEndpointURL: URL,
blogID: TaggedManagedObjectID<Blog>?,
in contextManager: ContextManager,
using keychainImplementation: KeychainAccessible = KeychainUtils()
) async throws -> TaggedManagedObjectID<Blog> {
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
Expand Down Expand Up @@ -183,13 +189,41 @@ public enum WordPressSite {
case selfHosted(blogId: TaggedManagedObjectID<Blog>, 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)
// 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())
}
}

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)
}
}
}
1 change: 1 addition & 0 deletions Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct ApplicationPasswordRequiredView<Content: View>: 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)
Expand Down
58 changes: 39 additions & 19 deletions WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blog>, username: String?)

var blogID: TaggedManagedObjectID<Blog>? {
switch self {
case .default:
return nil
case let .reauthentication(blogID, _):
return blogID
}
}
}

private static let callbackURL = URL(string: "x-wordpress-app://login-callback")!
Expand Down Expand Up @@ -78,30 +87,32 @@ struct SelfHostedSiteAuthenticator {

@MainActor
func signIn(site: String, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
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<Blog> {
func signIn(details: AutoDiscoveryAttemptSuccess, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
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

Expand All @@ -117,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
Expand Down Expand Up @@ -150,7 +164,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)
}

Expand All @@ -165,7 +179,13 @@ struct SelfHostedSiteAuthenticator {
// Only store the new site after credentials are validated.
let blog: TaggedManagedObjectID<Blog>
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
}
Expand Down
75 changes: 51 additions & 24 deletions WordPress/Classes/Networking/WordPressClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Blog>, 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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jkmassel FYI, I have reverted the comment API changes, due to lack of "likes" support in the REST API.

if let site = try? WordPressSite(blog: blog) {
return CommentServiceRemoteCoreRESTAPI(client: .init(site: site))
}
Expand Down
3 changes: 3 additions & 0 deletions WordPress/Classes/Services/MediaRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation
import WordPressUI
import SwiftUI

public 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")
}
Loading