- Extract OAuth UserDefaults keys into shared OAuthKeys enum - Extract triplicated mimeTypeFor() into shared mimeTypeForImage() - Cache normalized base URL at client init instead of per-request
197 lines
7.5 KiB
Swift
197 lines
7.5 KiB
Swift
import Foundation
|
|
|
|
struct WordPressClient: PostingService {
|
|
let account: Account
|
|
private let token: String
|
|
private let baseURL: String
|
|
|
|
init(account: Account, token: String) throws {
|
|
self.account = account
|
|
self.token = token // format: "username:application_password"
|
|
self.baseURL = try URLNormalizer.normalizeBaseURL(
|
|
account.instanceURL, fieldName: "WordPress site URL"
|
|
)
|
|
}
|
|
|
|
private var authHeader: String {
|
|
"Basic \(Data(token.utf8).base64EncodedString())"
|
|
}
|
|
|
|
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/media")
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = 30
|
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
request.setValue(mimeTypeForImage(filename), forHTTPHeaderField: "Content-Type")
|
|
request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition")
|
|
request.httpBody = imageData
|
|
|
|
let (data, response) = try await NetworkSupport.data(
|
|
for: request,
|
|
context: "WordPress media upload"
|
|
)
|
|
let statusCode = response.statusCode
|
|
|
|
guard (200...201).contains(statusCode) else {
|
|
let body = NetworkSupport.responseBody(data)
|
|
throw PostingError(message: "WordPress media upload failed (\(statusCode)): \(body)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let mediaID = json["id"] as? Int
|
|
else {
|
|
throw PostingError(message: "Invalid response from WordPress media upload")
|
|
}
|
|
|
|
// Update alt text if provided
|
|
if let altText, !altText.isEmpty {
|
|
try await updateMediaAltText(mediaID: mediaID, altText: altText)
|
|
}
|
|
|
|
guard let sourceURL = json["source_url"] as? String else {
|
|
throw PostingError(message: "No source_url in WordPress media response")
|
|
}
|
|
|
|
return sourceURL
|
|
}
|
|
|
|
private func updateMediaAltText(mediaID: Int, altText: String) async throws {
|
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/media/\(mediaID)")
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = 30
|
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["alt_text": altText])
|
|
|
|
let (_, response) = try await NetworkSupport.data(
|
|
for: request,
|
|
context: "WordPress media alt-text update"
|
|
)
|
|
let statusCode = response.statusCode
|
|
guard statusCode == 200 else {
|
|
throw PostingError(message: "Failed to update alt text (\(statusCode))")
|
|
}
|
|
}
|
|
|
|
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
|
// mediaIDs are source_url strings for WordPress
|
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/posts")
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = 30
|
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
var content = text
|
|
// Append images as HTML
|
|
for imageURL in mediaIDs {
|
|
content += "\n<img src=\"\(imageURL)\" />"
|
|
}
|
|
|
|
let body: [String: Any] = [
|
|
"content": content,
|
|
"status": "publish",
|
|
"format": "status",
|
|
]
|
|
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (data, response) = try await NetworkSupport.data(
|
|
for: request,
|
|
context: "WordPress post"
|
|
)
|
|
let statusCode = response.statusCode
|
|
|
|
guard (200...201).contains(statusCode) else {
|
|
let errorBody = NetworkSupport.responseBody(data)
|
|
throw PostingError(message: "WordPress post failed (\(statusCode)): \(errorBody)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let link = json["link"] as? String,
|
|
let postURL = URL(string: link)
|
|
else {
|
|
throw PostingError(message: "Invalid response from WordPress")
|
|
}
|
|
|
|
return postURL
|
|
}
|
|
|
|
// MARK: - Auth Discovery
|
|
|
|
static func discoverAuthEndpoint(siteURL: String) async throws -> String {
|
|
let url = try URLNormalizer.endpointURL(
|
|
baseURL: siteURL,
|
|
path: "/wp-json/",
|
|
fieldName: "WordPress site URL"
|
|
)
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 30
|
|
|
|
let (data, response) = try await NetworkSupport.data(
|
|
for: request,
|
|
context: "WordPress API discovery"
|
|
)
|
|
let statusCode = response.statusCode
|
|
|
|
guard statusCode == 200 else {
|
|
throw PostingError(message: "Cannot reach WordPress API at \(siteURL)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let auth = json["authentication"] as? [String: Any],
|
|
let appPasswords = auth["application-passwords"] as? [String: Any],
|
|
let endpoints = appPasswords["endpoints"] as? [String: Any],
|
|
let authURL = endpoints["authorization"] as? String
|
|
else {
|
|
throw PostingError(message: "Application Passwords not available on this site. Ensure WordPress 5.6+.")
|
|
}
|
|
|
|
return authURL
|
|
}
|
|
|
|
static func buildAuthURL(authEndpoint: String) -> URL {
|
|
guard var components = URLComponents(string: authEndpoint) else {
|
|
return URL(fileURLWithPath: "/")
|
|
}
|
|
components.queryItems = [
|
|
URLQueryItem(name: "app_name", value: "qStatus"),
|
|
URLQueryItem(name: "app_id", value: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
|
|
URLQueryItem(name: "success_url", value: "qstatus://wordpress-callback"),
|
|
URLQueryItem(name: "reject_url", value: "qstatus://wordpress-rejected"),
|
|
]
|
|
return components.url ?? URL(fileURLWithPath: "/")
|
|
}
|
|
|
|
static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool {
|
|
let url = try URLNormalizer.endpointURL(
|
|
baseURL: siteURL,
|
|
path: "/wp-json/wp/v2/users/me",
|
|
fieldName: "WordPress site URL"
|
|
)
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 30
|
|
let creds = "\(username):\(password)"
|
|
request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
|
|
|
|
let (data, response) = try await NetworkSupport.data(
|
|
for: request,
|
|
context: "WordPress credential verification"
|
|
)
|
|
let statusCode = response.statusCode
|
|
switch statusCode {
|
|
case 200:
|
|
return true
|
|
case 401, 403:
|
|
return false
|
|
case 404:
|
|
throw PostingError(message: "WordPress REST API not found at \(siteURL). Check the URL.")
|
|
default:
|
|
let body = NetworkSupport.responseBody(data)
|
|
throw PostingError(message: "Unexpected response (\(statusCode)): \(body)")
|
|
}
|
|
}
|
|
}
|