qstatus/qStatus/Services/WordPressClient.swift
2026-03-03 19:37:18 +01:00

217 lines
8 KiB
Swift

import Foundation
struct WordPressClient: PostingService {
let account: Account
private let token: String
private let rawBaseURL: String
init(account: Account, token: String) {
self.account = account
self.token = token // format: "username:application_password"
self.rawBaseURL = account.instanceURL
}
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(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/media",
fieldName: "WordPress site URL"
)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
request.setValue(mimeTypeFor(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(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/media/\(mediaID)",
fieldName: "WordPress site URL"
)
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(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/posts",
fieldName: "WordPress site URL"
)
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)")
}
}
private func mimeTypeFor(_ filename: String) -> String {
let ext = (filename as NSString).pathExtension.lowercased()
switch ext {
case "png": return "image/png"
case "gif": return "image/gif"
case "webp": return "image/webp"
default: return "image/jpeg"
}
}
}